feat(tui): add stacked alt-screen flashes (#7361)

This commit is contained in:
Armin Ronacher 2026-07-31 10:13:31 +02:00 • committed by GitHub
parent 57cde86906
commit 17090d4b99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 102 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts";
import { ScrollView } from "./components/scroll-view.ts";
import { getKeybindings } from "./keybindings.ts";
import { getScrollViewBox, getScrollViewsAt, type LayoutFrame, renderLayoutFrame } from "./layout.ts";
@ -10,7 +11,7 @@ import {
setCapabilities,
type TerminalCapabilities,
} from "./terminal-image.ts";
import { type Component, CURSOR_MARKER, TuiBase, VIEWPORT_TUI, type ViewportTUI } from "./tui.ts";
import { type Component, CURSOR_MARKER, compositeTuiLine, TuiBase, VIEWPORT_TUI, type ViewportTUI } from "./tui.ts";
import {
extractAnsiCode,
getGraphemeCellRange,
@ -69,6 +70,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
private currentLayout: LayoutFrame | undefined;
private readonly implicitDocument: Component;
private readonly implicitScrollView: ScrollView;
private readonly flashes: AltScreenFlashContainer;
private altScreenActive = false;
private imageProtocol: ImageProtocol = null;
private savedCapabilities?: TerminalCapabilities;
@ -97,6 +99,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
},
};
this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true });
this.flashes = new AltScreenFlashContainer(() => this.requestRender());
this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
this.mouseEnabled = options.mouse ?? true;
this.openUrl = options.openUrl;
@ -137,6 +140,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
protected override beforeTerminalStart(): void {
this.stopSelectionAutoScroll();
this.flashes.dispose();
this.altScreenActive = true;
const capabilities = getCapabilities();
this.imageProtocol = capabilities.images;
@ -158,6 +162,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
protected override beforeTerminalStop(): void {
this.stopSelectionAutoScroll();
this.flashes.dispose();
if (!this.altScreenActive) return;
this.terminal.write(
`${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`,
@ -211,6 +216,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.requestRender();
}
/** Show a transient message in the alternate-screen flash stack. */
flash(message: string, durationMs?: number): void {
this.flashes.flash(message, durationMs);
}
private handleViewportInput(data: string): { consume?: boolean } | undefined {
const wheelEvent = this.parseWheelEvent(data);
if (wheelEvent) {
@ -496,6 +506,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
const text = lines.join("\n");
if (text.length === 0) return;
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
this.flash("Copied!");
}
private applySelectionHighlight(text: string): string {
@ -568,6 +579,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) || (data.length === 6 && data.startsWith("\x1b[M"));
}
private compositeFlashes(screen: string[], width: number, height: number): string[] {
const flashLines = this.flashes.render(width).slice(-height);
if (flashLines.length === 0) return screen;
const result = [...screen];
while (result.length < height) result.push("");
for (let row = 0; row < flashLines.length; row++) {
const line = flashLines[row]!;
const flashWidth = visibleWidth(line);
if (flashWidth === 0) continue;
result[row] = compositeTuiLine(result[row] ?? "", line, width - flashWidth, flashWidth, width);
}
return result;
}
protected override doRender(): void {
if (this.stopped || !this.altScreenActive) return;
const width = Math.max(1, this.terminal.columns);
@ -578,6 +603,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
screen = this.compositeOverlays(screen, width, height);
if (screen.length > height) screen = screen.slice(screen.length - height);
screen = this.applySelection(screen, nextLayout);
screen = this.compositeFlashes(screen, width, height);
const cursorPos = this.extractCursorPosition(screen, height);
screen = this.applyLineResets(screen).map((line) => {

View file

@ -0,0 +1,51 @@
import type { Component } from "../tui.ts";
import { truncateToWidth } from "../utils.ts";
const DEFAULT_DURATION_MS = 1000;
interface FlashEntry {
id: number;
message: string;
timer: NodeJS.Timeout;
}
/** Transient messages composited by the alternate-screen renderer. */
export class AltScreenFlashContainer implements Component {
private readonly entries: FlashEntry[] = [];
private nextId = 0;
private readonly requestRender: () => void;
constructor(requestRender: () => void) {
this.requestRender = requestRender;
}
flash(message: string, durationMs = DEFAULT_DURATION_MS): void {
const id = this.nextId++;
const timer = setTimeout(
() => {
const index = this.entries.findIndex((entry) => entry.id === id);
if (index === -1) return;
this.entries.splice(index, 1);
this.requestRender();
},
Math.max(0, durationMs),
);
timer.unref();
this.entries.push({ id, message, timer });
this.requestRender();
}
dispose(): void {
for (const entry of this.entries) clearTimeout(entry.timer);
this.entries.length = 0;
}
invalidate(): void {}
render(width: number): string[] {
return this.entries.map((entry) => {
const message = truncateToWidth(` ${entry.message} `, width, "");
return `\x1b[7m${message}\x1b[27m`;
});
}
}

View file

@ -339,6 +339,30 @@ describe("TuiAltScreen", () => {
terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m\x1b[0m\x1b[7m")),
"selection inverse must be reapplied after layout segment resets",
);
assert.ok(terminal.getViewport().some((line) => line.includes("Copied!")));
tui.stop();
});
it("stacks flash messages and collapses them as they expire", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("one\ntwo\nthree\nfour", 0, 0));
tui.start();
await terminal.waitForRender();
tui.flash("First", 80);
tui.flash("Second", 500);
await terminal.waitForRender();
let viewport = terminal.getViewport();
assert.ok(viewport[0]?.endsWith(" First "));
assert.ok(viewport[1]?.endsWith(" Second "));
await new Promise((resolve) => setTimeout(resolve, 100));
await terminal.waitForRender();
viewport = terminal.getViewport();
assert.ok(viewport[0]?.endsWith(" Second "));
assert.ok(!viewport.some((line) => line.includes("First")));
tui.stop();
});