Merge PR #4407: restore terminal cursor behavior

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-08-02 17:02:51 -04:00
8 changed files with 190 additions and 4 deletions
@@ -139,6 +139,11 @@ pub struct CursorFrame {
pub struct Frame {
pub rows: Vec<RowFrame>,
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<u64>,
cursor: Option<CursorFrame>,
}
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,
}
@@ -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"
);
}
@@ -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,
@@ -247,6 +247,7 @@ mod tests {
column: 0,
visible: true,
},
cursor_changed: true,
full,
viewport,
}
@@ -11,6 +11,7 @@ let render;
let waitFor;
let ThemeProvider;
let TerminalSubstrate;
let reducedMotion = false;
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
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;
}
});
@@ -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 (
<section
@@ -134,3 +134,34 @@ test("stale viewport frames are rejected without mutating retained rows", () =>
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));
});
@@ -116,6 +116,7 @@ export class TerminalGrid {
#rows: RetainedRow[];
#dirty = new Set<number>();
#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,