mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
commit
feedd2fe7c
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user