mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
fix(desktop): make terminal output selectable (#4980)
## Summary - mirror the retained canvas terminal grid into a transparent, selectable text layer - preserve the canvas renderer and terminal focus behavior for ordinary clicks - reconstruct wide and combining glyphs correctly for clipboard text ## Why Buzz Term renders output entirely on a canvas and deliberately called `preventDefault()` on viewport mouse-down, so native selection and copy could not work. A canvas has no selectable text even if that cancellation is removed. The transparent text layer stays aligned with the visible cell grid, lets WebView native selection drive drag highlighting and copy, and follows active-session switches without changing the renderer or PTY protocol. ## Validation - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 4,373 passed - pre-push `desktop-check`, `desktop-test`, and `branch-skew` hooks passed on `1f2a3f8db63f6fe36b4a28bc911aea3c5186b2b0` --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
1
desktop/src-tauri/Cargo.lock
generated
1
desktop/src-tauri/Cargo.lock
generated
@@ -1021,6 +1021,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"getrandom 0.4.3",
|
||||
"hex",
|
||||
"nix 0.31.3",
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,6 +113,7 @@ pub(crate) struct WireSpan {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WireRow {
|
||||
line: usize,
|
||||
wrapped: bool,
|
||||
spans: Vec<WireSpan>,
|
||||
}
|
||||
|
||||
@@ -187,6 +188,7 @@ fn wire_publication(publication: Publication) -> Result<FrameMessage> {
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
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 {
|
||||
|
||||
@@ -240,6 +240,7 @@ mod tests {
|
||||
Frame {
|
||||
rows: vec![RowFrame {
|
||||
line: marker,
|
||||
wrapped: false,
|
||||
spans: Vec::new(),
|
||||
}],
|
||||
cursor: CursorFrame {
|
||||
|
||||
@@ -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界");
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user