diff --git a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs index 74d2503de..4c09de09b 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs @@ -139,6 +139,11 @@ pub struct CursorFrame { pub struct Frame { pub rows: Vec, pub cursor: CursorFrame, + /// Whether the cursor plane changed since this encoder's previous frame. + /// Cursor movement can be the only visible effect of input (for example, + /// echoing a space over an already blank cell), so it independently makes + /// an incremental frame publishable. + pub cursor_changed: bool, /// Whether the renderer should discard what it has and repaint. pub full: bool, /// The grid this frame describes. A change means the terminal was resized @@ -152,7 +157,7 @@ pub struct Frame { impl Frame { /// True when there is nothing for the renderer to do. pub fn is_empty(&self) -> bool { - self.rows.is_empty() && !self.full + self.rows.is_empty() && !self.cursor_changed && !self.full } } @@ -263,6 +268,7 @@ pub fn capture_all(terminal: &mut crate::Terminal) -> RawFrame { #[derive(Default)] pub struct Encoder { hashes: Vec, + cursor: Option, } impl Encoder { @@ -293,9 +299,12 @@ impl Encoder { spans: spans(&cells), }); } + let cursor_changed = self.cursor != Some(raw.cursor); + self.cursor = Some(raw.cursor); Frame { rows, cursor: raw.cursor, + cursor_changed, full: raw.full, viewport: raw.viewport, } diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs b/desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs new file mode 100644 index 000000000..7e43505b7 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs @@ -0,0 +1,41 @@ +use buzz_terminal::damage::Encoder; +use buzz_terminal::fences::Fences; +use buzz_terminal::{SharedTerminal, Size, Terminal}; + +#[test] +fn space_over_blank_cell_publishes_cursor_only_frame() { + let (terminal, _actions) = Terminal::new( + Size { + columns: 8, + screen_lines: 2, + scrollback: 10, + }, + Fences::ALL, + ); + let terminal = SharedTerminal::new(terminal); + let mut encoder = Encoder::new(); + + let initial = terminal.render(&mut encoder); + assert!(!initial.is_empty()); + assert_eq!(initial.cursor.column, 0); + + terminal.feed_fully(b" "); + let after_space = terminal.render(&mut encoder); + + assert!( + after_space.rows.is_empty(), + "a blank cell overwritten with a space must be row-deduplicated" + ); + assert_eq!(after_space.cursor.column, 1); + assert!(after_space.cursor_changed); + assert!( + !after_space.is_empty(), + "cursor movement must make the frame publishable" + ); + + let idle = terminal.render(&mut encoder); + assert!( + idle.is_empty(), + "an unchanged cursor must not create traffic" + ); +} diff --git a/desktop/src-tauri/src/terminal_runtime.rs b/desktop/src-tauri/src/terminal_runtime.rs index 9be896e9b..87d06a6e8 100644 --- a/desktop/src-tauri/src/terminal_runtime.rs +++ b/desktop/src-tauri/src/terminal_runtime.rs @@ -759,6 +759,7 @@ mod tests { column: 2, visible: true, }, + cursor_changed: true, full: true, viewport: Viewport { generation: 4, @@ -788,6 +789,7 @@ mod tests { column: 0, visible: true, }, + cursor_changed: true, full, viewport: Viewport { generation: 0, diff --git a/desktop/src-tauri/src/terminal_transport.rs b/desktop/src-tauri/src/terminal_transport.rs index c00a1c006..548cd3087 100644 --- a/desktop/src-tauri/src/terminal_transport.rs +++ b/desktop/src-tauri/src/terminal_transport.rs @@ -247,6 +247,7 @@ mod tests { column: 0, visible: true, }, + cursor_changed: true, full, viewport, } diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index 9302ded3f..8e9c2b44e 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -11,6 +11,7 @@ let render; let waitFor; let ThemeProvider; let TerminalSubstrate; +let reducedMotion = false; const dom = new JSDOM("", { url: "http://localhost", @@ -26,7 +27,9 @@ before(async () => { IS_REACT_ACT_ENVIRONMENT: true, }); dom.window.matchMedia = () => ({ - matches: false, + get matches() { + return reducedMotion; + }, addEventListener() {}, removeEventListener() {}, }); @@ -49,6 +52,7 @@ before(async () => { after(() => dom.window.close()); beforeEach(() => { cleanup?.(); + reducedMotion = false; dom.window.localStorage.clear(); dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({ bottom: 782, @@ -276,3 +280,66 @@ test("canvas failure atomically restores Buzz ownership", async () => { assert.equal(substrate.dataset.terminalOwner, "terminal"), ); }); + +test("cursor blink runs only while the terminal owns input and resets on input", async () => { + const originalSetInterval = window.setInterval; + const originalClearInterval = window.clearInterval; + const callbacks = new Map(); + let nextTimer = 1; + window.setInterval = (callback) => { + const timer = nextTimer++; + callbacks.set(timer, callback); + return timer; + }; + window.clearInterval = (timer) => callbacks.delete(timer); + try { + const subject = fixture({ frame: VISIBLE_FRAME }); + await ready(subject.view); + assert.equal(callbacks.size, 0, "Buzz ownership must pause blinking"); + + toggleChord(); + await waitFor(() => assert.equal(callbacks.size, 1)); + const firstTimer = [...callbacks.keys()][0]; + act(() => callbacks.get(firstTimer)()); + + fireEvent.input(subject.view.getByLabelText("Terminal input"), { + target: { value: "a" }, + }); + await waitFor(() => { + assert.deepEqual(subject.calls.input, ["a"]); + assert.equal(callbacks.size, 1); + assert.equal(callbacks.has(firstTimer), false); + }); + + toggleChord(); + await waitFor(() => assert.equal(callbacks.size, 0)); + } finally { + window.setInterval = originalSetInterval; + window.clearInterval = originalClearInterval; + } +}); + +test("reduced motion keeps the terminal cursor solid", async () => { + reducedMotion = true; + const originalSetInterval = window.setInterval; + let intervals = 0; + window.setInterval = () => { + intervals += 1; + return 1; + }; + try { + const subject = fixture({ frame: VISIBLE_FRAME }); + await ready(subject.view); + toggleChord(); + await waitFor(() => + assert.equal( + subject.view.container.querySelector(".buzz-terminal-substrate").dataset + .terminalOwner, + "terminal", + ), + ); + assert.equal(intervals, 0); + } finally { + window.setInterval = originalSetInterval; + } +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 3822e005a..1177494c3 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -112,6 +112,11 @@ export function TerminalSubstrate({ const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); const [welcomeVisible, setWelcomeVisible] = React.useState(true); + const [cursorPainted, setCursorPainted] = React.useState(true); + const [cursorReset, setCursorReset] = React.useState(0); + const [reducedMotion, setReducedMotion] = React.useState( + () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, + ); const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘J" : "CTRL+J"; @@ -167,6 +172,8 @@ export function TerminalSubstrate({ const sendInput = React.useEffectEvent((text: string) => { if (!text) return; setWelcomeVisible(false); + setCursorPainted(true); + setCursorReset((current) => current + 1); onInput(text); }); const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => { @@ -193,6 +200,26 @@ export function TerminalSubstrate({ if (!enabled) forceBuzzFallback(); }, [enabled]); + React.useEffect(() => { + const preference = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(preference.matches); + preference.addEventListener("change", update); + return () => preference.removeEventListener("change", update); + }, []); + + // Input activity is intentionally an effect trigger: restarting this timer is + // what resets the blink phase even when the cursor is already painted. + // biome-ignore lint/correctness/useExhaustiveDependencies: cursorReset restarts the timer by design. + React.useEffect(() => { + setCursorPainted(true); + if (owner !== "terminal" || reducedMotion) return; + const timer = window.setInterval( + () => setCursorPainted((painted) => !painted), + 500, + ); + return () => window.clearInterval(timer); + }, [cursorReset, owner, reducedMotion]); + React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -353,8 +380,9 @@ export function TerminalSubstrate({ context.fillStyle = terminalPalette.background; context.fillRect(0, 0, bounds.width, bounds.height); } + gridRef.current?.setCursorPainted(cursorPainted); gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette); - }, [activeSessionId, frames, terminalPalette]); + }, [activeSessionId, cursorPainted, frames, terminalPalette]); return (
false, ); }); + +test("cursor visibility can blink without a new terminal frame", () => { + const grid = new TerminalGrid({ generation: 0, columns: 4, screenLines: 1 }); + grid.apply({ + viewport: grid.viewport, + full: true, + cursor: { line: 0, column: 2, visible: true }, + rows: [], + }); + + const visible = context(); + grid.paint(visible, metrics, palette); + assert.ok(visible.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2)); + + grid.setCursorPainted(false); + const hidden = context(); + grid.paint(hidden, metrics, palette); + assert.equal( + hidden.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2), + false, + ); + assert.ok( + hidden.fills.some((fill) => fill[0] === 0 && fill[2] === 40), + "hiding the cursor must repaint its row to erase the old caret", + ); + + grid.setCursorPainted(true); + const restored = context(); + grid.paint(restored, metrics, palette); + assert.ok(restored.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2)); +}); diff --git a/desktop/src/features/terminal/terminalRenderer.ts b/desktop/src/features/terminal/terminalRenderer.ts index 6977f17c5..085679408 100644 --- a/desktop/src/features/terminal/terminalRenderer.ts +++ b/desktop/src/features/terminal/terminalRenderer.ts @@ -116,6 +116,7 @@ export class TerminalGrid { #rows: RetainedRow[]; #dirty = new Set(); #cursor: TerminalCursor = { line: 0, column: 0, visible: false }; + #cursorPainted = true; constructor(viewport: TerminalViewport) { this.#viewport = viewport; @@ -161,6 +162,12 @@ export class TerminalGrid { } } + setCursorPainted(painted: boolean): void { + if (this.#cursorPainted === painted) return; + this.#cursorPainted = painted; + this.#dirty.add(this.#cursor.line); + } + paint( context: PaintContext, metrics: CellMetrics, @@ -210,7 +217,7 @@ export class TerminalGrid { } } } - if (this.#cursor.visible) { + if (this.#cursor.visible && this.#cursorPainted) { context.fillStyle = palette.cursor; context.fillRect( this.#cursor.column * metrics.width,