| 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 | |
| 6 | import type { CellSnapshot } from "./ghostty/terminal.ts"; |
| 7 | import type { DetectedLink } from "./links.ts"; |
| 8 | |
| 9 | export 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 | |
| 21 | export interface RowText { |
| 22 | text: string; |
| 23 | /** `cellAt[offset]` identifies the canonical cell that owns that UTF-16 code unit. */ |
| 24 | cellAt: number[]; |
| 25 | } |
| 26 | |
| 27 | function 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. */ |
| 47 | export 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. */ |
| 53 | export 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 | */ |
| 68 | export 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 | |
| 84 | export 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 | } |