1// Pure cell-run grouping: consecutive cells with identical presentation
2// collapse into one run so a DOM row is a handful of spans, not cols spans.
3// Links participate in the grouping (rather than splitting runs afterwards)
4// so a run never straddles a link boundary and column math stays exact.
5
6import type { CellSnapshot } from "./ghostty/terminal.ts";
7import type { DetectedLink } from "./links.ts";
8
9export interface CellRun {
10 text: string;
11 /** Total columns covered (wide cells count 2). */
12 columns: number;
13 /** Canonical references; run grouping must not allocate a second cell model. */
14 cells: CellSnapshot[];
15 /** Index into the row's link list, or -1. */
16 link: number;
17 /** Speculative local echo, not yet confirmed by the pty. */
18 predicted: boolean;
19}
20
21export interface RowText {
22 text: string;
23 /** `cellAt[offset]` identifies the canonical cell that owns that UTF-16 code unit. */
24 cellAt: number[];
25}
26
27function sameStyle(a: CellSnapshot, b: CellSnapshot): boolean {
28 return (
29 a.fg === b.fg &&
30 a.hasOsc8 === b.hasOsc8 &&
31 a.osc8Uri === b.osc8Uri &&
32 a.bg === b.bg &&
33 a.underlineColor === b.underlineColor &&
34 a.bold === b.bold &&
35 a.italic === b.italic &&
36 a.faint === b.faint &&
37 a.blink === b.blink &&
38 a.inverse === b.inverse &&
39 a.invisible === b.invisible &&
40 a.strikethrough === b.strikethrough &&
41 a.overline === b.overline &&
42 a.underline === b.underline
43 );
44}
45
46/** Program text for one cell. Spacer heads own geometry but carry no text. */
47export function cellText(cell: CellSnapshot): string {
48 if (cell.spacer === "head") return "";
49 return cell.text === "" ? " " : cell.text;
50}
51
52/** Concatenated row text, plus the cell index each character came from. */
53export function rowText(cells: readonly CellSnapshot[]): RowText {
54 let text = "";
55 const cellAt: number[] = [];
56 for (let i = 0; i < cells.length; i++) {
57 const piece = cellText(cells[i]!);
58 text += piece;
59 for (let c = 0; c < piece.length; c++) cellAt.push(i);
60 }
61 return { text, cellAt };
62}
63
64/**
65 * Map detected links (text offsets) onto cells: `out[cellIndex]` is the link's
66 * index, or -1. A cell belongs to the link that covers its first character.
67 */
68export function linkCells(
69 cells: readonly CellSnapshot[],
70 links: readonly DetectedLink[],
71 cellAt: readonly number[],
72): number[] {
73 const out: number[] = Array.from({ length: cells.length }, () => -1);
74 for (let l = 0; l < links.length; l++) {
75 const link = links[l]!;
76 for (let i = link.start; i < link.end && i < cellAt.length; i++) {
77 const cell = cellAt[i]!;
78 if (out[cell] === -1) out[cell] = l;
79 }
80 }
81 return out;
82}
83
84export function cellsToRuns(
85 cells: readonly CellSnapshot[],
86 links?: readonly number[],
87 predicted?: ReadonlySet<number>,
88): CellRun[] {
89 const runs: CellRun[] = [];
90 let current: CellRun | null = null;
91 let anchor: CellSnapshot | null = null;
92 for (let i = 0; i < cells.length; i++) {
93 const cell = cells[i]!;
94 const link = links?.[i] ?? -1;
95 const speculative = predicted?.has(i) ?? false;
96 if (
97 current !== null &&
98 anchor !== null &&
99 sameStyle(anchor, cell) &&
100 current.link === link &&
101 current.predicted === speculative
102 ) {
103 current.text += cellText(cell);
104 current.columns += cell.width;
105 current.cells.push(cell);
106 continue;
107 }
108 current = {
109 text: cellText(cell),
110 columns: cell.width,
111 cells: [cell],
112 link,
113 predicted: speculative,
114 };
115 anchor = cell;
116 runs.push(current);
117 }
118 return runs;
119}