diff --git a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs index 0cc41e1685..c4bed4f1f3 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs @@ -118,6 +118,10 @@ pub struct Style { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RowFrame { pub line: usize, + /// Whether this row continues onto the next screen row without a hard + /// line break. Retained separately from visual style so copy serialization + /// can reconstruct logical lines without exposing geometry flags to spans. + pub wrapped: bool, pub spans: Vec, } @@ -332,6 +336,9 @@ impl Encoder { self.hashes[line] = hash; rows.push(RowFrame { line, + wrapped: cells + .last() + .is_some_and(|cell| cell.flags.contains(Flags::WRAPLINE)), spans: spans(&cells), }); } diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs index 9486aa8742..a8bb94b02f 100644 --- a/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs +++ b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs @@ -306,6 +306,10 @@ fn wrapping_does_not_split_a_uniform_run() { .iter() .find(|row| row.line == 0) .expect("wrapped row must be present"); + assert!( + first.wrapped, + "soft-wrap geometry must survive row encoding" + ); let texts: Vec<&str> = first.spans.iter().map(|s| s.text.as_str()).collect(); assert_eq!( texts, diff --git a/desktop/src-tauri/src/terminal_runtime.rs b/desktop/src-tauri/src/terminal_runtime.rs index 87f969592d..840c0ee257 100644 --- a/desktop/src-tauri/src/terminal_runtime.rs +++ b/desktop/src-tauri/src/terminal_runtime.rs @@ -113,6 +113,7 @@ pub(crate) struct WireSpan { #[serde(rename_all = "camelCase")] pub(crate) struct WireRow { line: usize, + wrapped: bool, spans: Vec, } @@ -187,6 +188,7 @@ fn wire_publication(publication: Publication) -> Result { .collect::>>()?; Ok(WireRow { line: row.line, + wrapped: row.wrapped, spans, }) }) @@ -819,7 +821,11 @@ mod tests { subscription_id: SubscriptionId::new(), sequence: 7, frame: buzz_terminal::damage::Frame { - rows: vec![RowFrame { line: 3, spans }], + rows: vec![RowFrame { + line: 3, + wrapped: true, + spans, + }], cursor: CursorFrame { line: 1, column: 2, @@ -848,6 +854,7 @@ mod tests { Frame { rows: vec![RowFrame { line: marker, + wrapped: false, spans: Vec::new(), }], cursor: CursorFrame { @@ -922,6 +929,12 @@ mod tests { assert_post_snapshot_capture_survives_attach(publisher); } + #[test] + fn mapper_preserves_soft_wrap_metadata() { + let message = wire_publication(publication(Vec::new())).unwrap(); + assert!(message.rows[0].wrapped); + } + #[test] fn mapper_expands_ascii_runs_without_unicode_classification() { let message = wire_publication(publication(vec![Span { diff --git a/desktop/src-tauri/src/terminal_transport.rs b/desktop/src-tauri/src/terminal_transport.rs index 548cd3087a..b6f4484428 100644 --- a/desktop/src-tauri/src/terminal_transport.rs +++ b/desktop/src-tauri/src/terminal_transport.rs @@ -240,6 +240,7 @@ mod tests { Frame { rows: vec![RowFrame { line: marker, + wrapped: false, spans: Vec::new(), }], cursor: CursorFrame { diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index ef138396b9..d6113cbf08 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -558,6 +558,7 @@ function frameWith(text, generation = 1) { rows: [ { line: 0, + wrapped: false, spans: [ { style: { fg: 0, bg: 0, flags: 0 }, @@ -857,3 +858,180 @@ test("the handoff chord still toggles with the tab layer installed", async () => // Splash animation lifecycle. // // This substrate is mounted unconditionally on every route and merely + +test("mirrors the active canvas grid into a selectable plain-text layer", async () => { + const subject = fixture({ + sessionFrames: [ + { frame: frameWith("one"), sessionId: "one" }, + { frame: frameWith("two"), sessionId: "two" }, + ], + sessions: TWO_SESSIONS, + }); + await ready(subject.view); + const selectionLayer = subject.view.container.querySelector( + ".buzz-terminal-selection-layer", + ); + await waitFor(() => + assert.equal( + selectionLayer.querySelector("[data-terminal-selection-row='0']") + .textContent, + "one", + ), + ); + + subject.rerender({ sessions: SWAPPED_SESSIONS }); + await waitFor(() => + assert.equal( + selectionLayer.querySelector("[data-terminal-selection-row='0']") + .textContent, + "two", + ), + ); +}); + +test("lays out screen rows separately but copies soft wraps as one logical line", async () => { + const frame = { + cursor: { column: 0, line: 0, visible: false }, + full: true, + rows: [ + { + line: 0, + wrapped: true, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "a", width: 1 }, + { column: 1, text: "b", width: 1 }, + { column: 2, text: "c", width: 1 }, + { column: 3, text: "d", width: 1 }, + { column: 4, text: " ", width: 1 }, + ], + }, + ], + }, + { + line: 1, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "é", width: 1 }, + { column: 1, text: "f", width: 1 }, + ], + }, + ], + }, + ], + viewport: { columns: 5, generation: 1, screenLines: 2 }, + }; + const subject = fixture({ + sessionFrames: [{ frame, sessionId: "one" }], + }); + await ready(subject.view); + const selectionLayer = subject.view.container.querySelector( + ".buzz-terminal-selection-layer", + ); + await waitFor(() => + assert.equal( + selectionLayer.querySelectorAll("[data-terminal-selection-row]").length, + 2, + ), + ); + const rows = selectionLayer.querySelectorAll("[data-terminal-selection-row]"); + assert.equal(rows[0].textContent, "abcd "); + assert.equal(rows[1].textContent, "éf"); + + const selection = window.getSelection(); + selection.removeAllRanges(); + const range = document.createRange(); + range.setStart(rows[0].firstChild, 1); + range.setEnd(rows[1].firstChild, rows[1].textContent.length); + selection.addRange(range); + const copied = new Map(); + fireEvent.copy(rows[0].parentElement, { + clipboardData: { setData: (type, value) => copied.set(type, value) }, + }); + assert.equal(copied.get("text/plain"), "bcd éf"); +}); + +test("copy normalizes grapheme and empty-row DOM endpoints", async () => { + const frame = { + cursor: { column: 0, line: 0, visible: false }, + full: true, + rows: [ + { + line: 0, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "😀", width: 2 }, + { column: 2, text: "é", width: 1 }, + ], + }, + ], + }, + { line: 1, wrapped: false, spans: [] }, + { + line: 2, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "界", width: 2 }], + }, + ], + }, + ], + viewport: { columns: 5, generation: 1, screenLines: 3 }, + }; + const subject = fixture({ sessionFrames: [{ frame, sessionId: "one" }] }); + await ready(subject.view); + const layer = subject.view.container.querySelector( + ".buzz-terminal-selection-layer", + ); + await waitFor(() => + assert.equal( + layer.querySelectorAll("[data-terminal-selection-row]").length, + 3, + ), + ); + const rows = layer.querySelectorAll("[data-terminal-selection-row]"); + const copyRange = (range) => { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + const copied = new Map(); + fireEvent.copy(layer, { + clipboardData: { setData: (type, value) => copied.set(type, value) }, + }); + return copied.get("text/plain"); + }; + + const splitEmoji = document.createRange(); + splitEmoji.setStart(rows[0].firstChild, 1); + splitEmoji.setEnd(rows[0].firstChild, 1); + // A collapsed native selection does not dispatch custom clipboard content; + // span from the middle of the emoji into the combining cluster instead. + splitEmoji.setEnd(rows[0].firstChild, 3); + assert.equal(copyRange(splitEmoji), "😀é"); + + const throughBlank = document.createRange(); + throughBlank.setStart(rows[0], 1); + throughBlank.setEnd(rows[2], 0); + assert.equal(copyRange(throughBlank), "\n\n"); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.setBaseAndExtent(rows[2].firstChild, 1, rows[0].firstChild, 0); + const reverseCopied = new Map(); + fireEvent.copy(layer, { + clipboardData: { + setData: (type, value) => reverseCopied.set(type, value), + }, + }); + assert.equal(reverseCopied.get("text/plain"), "😀é\n\n界"); +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 81ac91528a..d8aaee2e2d 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -19,6 +19,7 @@ import { buildBannerColorTable, phaseAt } from "./terminalBannerWave"; import { TERMINAL_CELL_METRICS, type TerminalFrame, + type TerminalSelectionRow, TerminalGrid, } from "./terminalRenderer"; @@ -128,6 +129,9 @@ export function TerminalSubstrate({ ); const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); + const [selectionRows, setSelectionRows] = React.useState< + readonly TerminalSelectionRow[] + >([]); const [welcomeVisible, setWelcomeVisible] = React.useState(false); const [cursorPainted, setCursorPainted] = React.useState(true); const [cursorReset, setCursorReset] = React.useState(0); @@ -457,6 +461,7 @@ export function TerminalSubstrate({ gridRef.current = activeSessionId ? (gridsRef.current.get(activeSessionId) ?? null) : null; + setSelectionRows(gridRef.current?.selectionRows() ?? []); paintTerminal(); }, [activeSessionId, cursorPainted, frames, terminalPalette]); @@ -672,17 +677,84 @@ export function TerminalSubstrate({ - {/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */} -
{ - // Preventing the canvas mousedown also suppresses selection. Revisit - // this when the terminal gains mouse selection support. - event.preventDefault(); - textareaRef.current?.focus({ preventScroll: true }); - }} - > +
+ {welcomeVisible && banner ? ( ) : null} diff --git a/desktop/src/features/terminal/terminalRenderer.test.mjs b/desktop/src/features/terminal/terminalRenderer.test.mjs index e718310f5a..97c3ca925e 100644 --- a/desktop/src/features/terminal/terminalRenderer.test.mjs +++ b/desktop/src/features/terminal/terminalRenderer.test.mjs @@ -68,6 +68,7 @@ test("clusters draw at computed columns, never accumulated text width", () => { rows: [ { line: 0, + wrapped: false, spans: [ { style: { fg: 0x01000007, bg: 0x01000101, flags: 0 }, @@ -108,6 +109,7 @@ test("combining marks stay in one cluster and consume one cell", () => { rows: [ { line: 0, + wrapped: false, spans: [ { style: { fg: 0x01000007, bg: 0x01000101, flags: 0 }, @@ -165,3 +167,84 @@ test("cursor visibility can blink without a new terminal frame", () => { grid.paint(restored, metrics, palette); assert.ok(restored.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2)); }); + +test("text preserves soft-wrap spaces and hard line breaks", () => { + const grid = new TerminalGrid({ generation: 0, columns: 5, screenLines: 3 }); + grid.apply({ + viewport: grid.viewport, + full: true, + cursor: { line: 0, column: 0, visible: false }, + rows: [ + { + line: 0, + wrapped: true, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [..."abcd "].map((text, column) => ({ + column, + text, + width: 1, + })), + }, + ], + }, + { + line: 1, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "é", width: 1 }, + { column: 1, text: "f", width: 1 }, + ], + }, + ], + }, + { + line: 2, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "tail", width: 1 }], + }, + ], + }, + ], + }); + + assert.equal(grid.text(), "abcd éf\ntail"); +}); + +test("selection offsets expand to complete grapheme clusters and clamp empty rows", () => { + const grid = new TerminalGrid({ generation: 0, columns: 5, screenLines: 2 }); + grid.apply({ + viewport: grid.viewport, + full: true, + cursor: { line: 0, column: 0, visible: false }, + rows: [ + { + line: 0, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "😀", width: 2 }, + { column: 2, text: "é", width: 1 }, + ], + }, + ], + }, + { line: 1, wrapped: false, spans: [] }, + ], + }); + + assert.equal(grid.normalizeSelectionOffset(0, 1, "start"), 0); + assert.equal(grid.normalizeSelectionOffset(0, 1, "end"), 2); + assert.equal(grid.normalizeSelectionOffset(0, 3, "start"), 2); + assert.equal(grid.normalizeSelectionOffset(0, 3, "end"), 4); + assert.equal(grid.normalizeSelectionOffset(1, 1, "end"), 0); +}); diff --git a/desktop/src/features/terminal/terminalRenderer.ts b/desktop/src/features/terminal/terminalRenderer.ts index 085679408a..8b46899710 100644 --- a/desktop/src/features/terminal/terminalRenderer.ts +++ b/desktop/src/features/terminal/terminalRenderer.ts @@ -23,7 +23,11 @@ export type TerminalSpan = { clusters: readonly TerminalCluster[]; }; -export type TerminalRow = { line: number; spans: readonly TerminalSpan[] }; +export type TerminalRow = { + line: number; + wrapped: boolean; + spans: readonly TerminalSpan[]; +}; export type TerminalCursor = { line: number; column: number; @@ -52,7 +56,17 @@ export const TERMINAL_CELL_METRICS = { boldFont: '700 14px "JetBrains Mono", monospace', } as const satisfies CellMetrics; -type RetainedRow = readonly TerminalSpan[]; +export type TerminalSelectionRow = { + boundaries: readonly number[]; + line: number; + text: string; + wrapped: boolean; +}; + +type RetainedRow = { + wrapped: boolean; + spans: readonly TerminalSpan[]; +}; export type PaintContext = Pick< CanvasRenderingContext2D, @@ -120,7 +134,10 @@ export class TerminalGrid { constructor(viewport: TerminalViewport) { this.#viewport = viewport; - this.#rows = Array.from({ length: viewport.screenLines }, () => []); + this.#rows = Array.from({ length: viewport.screenLines }, () => ({ + wrapped: false, + spans: [], + })); this.markAllDirty(); } @@ -128,6 +145,87 @@ export class TerminalGrid { return this.#viewport; } + selectionRows(): readonly TerminalSelectionRow[] { + return this.#rows.map((row, line) => { + const cells = Array.from({ length: this.#viewport.columns }, () => " "); + for (const span of row.spans) { + for (const cluster of span.clusters) { + cells[cluster.column] = cluster.text; + for (let offset = 1; offset < cluster.width; offset++) { + cells[cluster.column + offset] = ""; + } + } + } + const text = cells.join(""); + const retainedText = row.wrapped ? text : text.trimEnd(); + const boundaries = [0]; + for (const segment of new Intl.Segmenter(undefined, { + granularity: "grapheme", + }).segment(retainedText)) { + boundaries.push(segment.index + segment.segment.length); + } + return { + boundaries, + line, + text: retainedText, + wrapped: row.wrapped, + }; + }); + } + + normalizeSelectionOffset( + rowIndex: number, + offset: number, + edge: "start" | "end", + ): number { + const row = this.selectionRows()[rowIndex]; + if (!row) return 0; + const clamped = Math.max(0, Math.min(offset, row.text.length)); + if (edge === "start") { + for (let index = row.boundaries.length - 1; index >= 0; index--) { + if (row.boundaries[index] <= clamped) return row.boundaries[index]; + } + return 0; + } + return ( + row.boundaries.find((boundary) => boundary >= clamped) ?? row.text.length + ); + } + + selectionText( + startRow: number, + startOffset: number, + endRow: number, + endOffset: number, + ): string { + const rows = this.selectionRows(); + if ( + startRow < 0 || + endRow < startRow || + endRow >= rows.length || + startOffset < 0 || + endOffset < 0 + ) { + return ""; + } + let selected = ""; + for (let index = startRow; index <= endRow; index++) { + const row = rows[index]; + const from = index === startRow ? startOffset : 0; + const to = index === endRow ? endOffset : row.text.length; + selected += row.text.slice(from, to); + if (index < endRow && !row.wrapped) selected += "\n"; + } + return selected; + } + + text(): string { + return this.selectionRows() + .map((row) => (row.wrapped ? row.text : `${row.text}\n`)) + .join("") + .replace(/\n$/, ""); + } + apply(frame: TerminalFrame): boolean { if ( frame.viewport.generation !== this.#viewport.generation || @@ -142,7 +240,7 @@ export class TerminalGrid { this.#dirty.add(this.#cursor.line); for (const row of frame.rows) { if (row.line < this.#rows.length) { - this.#rows[row.line] = row.spans; + this.#rows[row.line] = { wrapped: row.wrapped, spans: row.spans }; this.#dirty.add(row.line); } } @@ -151,7 +249,10 @@ export class TerminalGrid { resize(viewport: TerminalViewport): void { this.#viewport = viewport; - this.#rows = Array.from({ length: viewport.screenLines }, () => []); + this.#rows = Array.from({ length: viewport.screenLines }, () => ({ + wrapped: false, + spans: [], + })); this.#cursor = { line: 0, column: 0, visible: false }; this.markAllDirty(); } @@ -186,7 +287,7 @@ export class TerminalGrid { this.#viewport.columns * metrics.width, metrics.height, ); - for (const span of this.#rows[line] ?? []) { + for (const span of this.#rows[line]?.spans ?? []) { const background = resolvePackedColor( span.style.bg, palette, diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css index 26b5f42ba8..544f0ae2ef 100644 --- a/desktop/src/shared/styles/globals/terminal.css +++ b/desktop/src/shared/styles/globals/terminal.css @@ -183,6 +183,31 @@ display: block; } + .buzz-terminal-selection-layer { + bottom: 0; + color: transparent; + font: + 14px / 17px "JetBrains Mono", + monospace; + left: 1.25rem; + margin: 0; + overflow: hidden; + position: absolute; + right: 1.25rem; + top: 0.5rem; + user-select: text; + white-space: pre; + } + + .buzz-terminal-selection-layer > div { + height: 17px; + } + + .buzz-terminal-selection-layer::selection { + background: hsl(var(--accent) / 0.65); + color: transparent; + } + .buzz-terminal-welcome { inset: 0; pointer-events: none;