Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions desktop/src-tauri/crates/buzz-terminal/src/damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Span>,
}

Expand Down Expand Up @@ -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),
});
}
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion desktop/src-tauri/src/terminal_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ pub(crate) struct WireSpan {
#[serde(rename_all = "camelCase")]
pub(crate) struct WireRow {
line: usize,
wrapped: bool,
spans: Vec<WireSpan>,
}

Expand Down Expand Up @@ -187,6 +188,7 @@ fn wire_publication(publication: Publication) -> Result<FrameMessage> {
.collect::<Result<Vec<_>>>()?;
Ok(WireRow {
line: row.line,
wrapped: row.wrapped,
spans,
})
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -848,6 +854,7 @@ mod tests {
Frame {
rows: vec![RowFrame {
line: marker,
wrapped: false,
spans: Vec::new(),
}],
cursor: CursorFrame {
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/terminal_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ mod tests {
Frame {
rows: vec![RowFrame {
line: marker,
wrapped: false,
spans: Vec::new(),
}],
cursor: CursorFrame {
Expand Down
178 changes: 178 additions & 0 deletions desktop/src/features/terminal/TerminalSubstrate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ function frameWith(text, generation = 1) {
rows: [
{
line: 0,
wrapped: false,
spans: [
{
style: { fg: 0, bg: 0, flags: 0 },
Expand Down Expand Up @@ -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界");
});
92 changes: 82 additions & 10 deletions desktop/src/features/terminal/TerminalSubstrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { buildBannerColorTable, phaseAt } from "./terminalBannerWave";
import {
TERMINAL_CELL_METRICS,
type TerminalFrame,
type TerminalSelectionRow,
TerminalGrid,
} from "./terminalRenderer";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -672,17 +677,84 @@ export function TerminalSubstrate({
</button>
</div>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
<div
className="buzz-terminal-viewport px-5 pt-2"
onMouseDown={(event) => {
// Preventing the canvas mousedown also suppresses selection. Revisit
// this when the terminal gains mouse selection support.
event.preventDefault();
textareaRef.current?.focus({ preventScroll: true });
}}
>
<div className="buzz-terminal-viewport px-5 pt-2">
<canvas ref={canvasRef} />
<div
aria-hidden="true"
className="buzz-terminal-selection-layer"
onCopy={(event) => {
const selection = window.getSelection();
const grid = gridRef.current;
if (!selection || selection.isCollapsed || !grid) return;
const rowFor = (node: Node | null) =>
(node?.nodeType === 1
? (node as Element)
: node?.parentElement
)?.closest<HTMLElement>("[data-terminal-selection-row]");
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (!anchorNode || !focusNode) return;
let startRow = rowFor(anchorNode);
let endRow = rowFor(focusNode);
if (!startRow || !endRow) return;
let startIndex = Number(startRow.dataset.terminalSelectionRow);
let endIndex = Number(endRow.dataset.terminalSelectionRow);
const offsetInRow = (
row: HTMLElement,
node: Node,
offset: number,
) => {
const range = document.createRange();
range.selectNodeContents(row);
range.setEnd(node, offset);
return range.toString().length;
};
let startOffset = offsetInRow(
startRow,
anchorNode,
selection.anchorOffset,
);
let endOffset = offsetInRow(
endRow,
focusNode,
selection.focusOffset,
);
if (
startIndex > endIndex ||
(startIndex === endIndex && startOffset > endOffset)
) {
[startRow, endRow] = [endRow, startRow];
[startIndex, endIndex] = [endIndex, startIndex];
[startOffset, endOffset] = [endOffset, startOffset];
}
startOffset = grid.normalizeSelectionOffset(
startIndex,
startOffset,
"start",
);
endOffset = grid.normalizeSelectionOffset(
endIndex,
endOffset,
"end",
);
event.preventDefault();
event.clipboardData.setData(
"text/plain",
grid.selectionText(startIndex, startOffset, endIndex, endOffset),
);
}}
onMouseUp={() => {
if (window.getSelection()?.isCollapsed !== false) {
textareaRef.current?.focus({ preventScroll: true });
}
}}
>
{selectionRows.map((row) => (
<div data-terminal-selection-row={row.line} key={row.line}>
{row.text || "\u00a0"}
</div>
))}
</div>
{welcomeVisible && banner ? (
<canvas className="buzz-terminal-welcome" ref={bannerCanvasRef} />
) : null}
Expand Down
Loading
Loading