char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somterminal: render styled text runs in normal flowc52d3a7

main
7.3 KiB203 linesraw
1import type { Cursor, Frame, Run } from "./ghostty.ts";
2import { COLORS, readable } from "./palette.ts";
3
4export type Metrics = {
5  width: number;
6  height: number;
7  ratio: number;
8  cell: { width: number; height: number };
9};
10const css = ([r, g, b]: readonly number[]) => `rgb(${r} ${g} ${b})`;
11const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
12
13/** A painted cell grid with real text above it for native selection and copying. */
14export class Grid {
15  readonly #rows: HTMLElement;
16  readonly #canvas: HTMLCanvasElement;
17  readonly #context: CanvasRenderingContext2D | null;
18  readonly #lines: {
19    element: HTMLElement;
20    runs: { run: Run; element: HTMLElement; start: number; end: number }[];
21    glyphs: { element: HTMLElement; start: number; end: number }[];
22  }[] = [];
23  #cursor: Cursor | null = null;
24  #focused = false;
25  #metrics: Metrics = { width: 0, height: 0, ratio: 1, cell: { width: 8, height: 18 } };
26
27  constructor(rows: HTMLElement, canvas: HTMLCanvasElement) {
28    this.#rows = rows;
29    this.#canvas = canvas;
30    this.#context = canvas.getContext("2d");
31  }
32
33  fit(metrics: Metrics) {
34    const old = this.#metrics;
35    if (
36      metrics.width === old.width && metrics.height === old.height &&
37      metrics.ratio === old.ratio && metrics.cell.width === old.cell.width &&
38      metrics.cell.height === old.cell.height
39    ) return;
40    this.#metrics = metrics;
41    this.#canvas.width = Math.max(1, Math.round(metrics.width * metrics.ratio));
42    this.#canvas.height = Math.max(1, Math.round(metrics.height * metrics.ratio));
43    this.#canvas.style.width = `${this.#canvas.width / metrics.ratio}px`;
44    this.#canvas.style.height = `${this.#canvas.height / metrics.ratio}px`;
45    for (let y = 0; y < this.#lines.length; y++) this.#position(y);
46    this.#paint();
47  }
48
49  update(frame: Frame) {
50    while (this.#lines.length < frame.rows) {
51      this.#lines.push({
52        element: this.#rows.appendChild(document.createElement("div")),
53        runs: [],
54        glyphs: [],
55      });
56    }
57    while (this.#lines.length > frame.rows) this.#lines.pop()!.element.remove();
58    for (const [y, runs] of frame.changed) {
59      this.#text(y, runs);
60    }
61    this.#cursor = frame.cursor;
62    this.#paint();
63  }
64
65  focus(focused: boolean) {
66    this.#focused = focused;
67    this.#paint();
68  }
69
70  #x(column: number) {
71    return Math.round(column * this.#metrics.cell.width * this.#metrics.ratio);
72  }
73
74  #y(row: number) {
75    return Math.round(row * this.#metrics.cell.height * this.#metrics.ratio);
76  }
77
78  #text(y: number, runs: Run[]) {
79    const line = this.#lines[y];
80    line.runs = [];
81    line.glyphs = [];
82    let column = 0;
83    const spans = runs.map((run) => {
84      const start = column;
85      const span = document.createElement("span");
86      span.style.color = run.invisible
87        ? "transparent"
88        : css(readable(run.fg ?? COLORS.foreground, run.bg ?? COLORS.background));
89      if (run.bold) span.style.fontWeight = "bold";
90      if (run.italic) span.style.fontStyle = "italic";
91      if (run.faint) span.style.opacity = "0.6";
92      span.className = "terminal-run";
93      for (const { segment } of graphemes.segment(run.text)) {
94        const end = column + (run.wide ? 2 : 1);
95        if (run.wide) {
96          const glyph = document.createElement("span");
97          glyph.className = "terminal-glyph";
98          glyph.textContent = segment;
99          line.glyphs.push({ element: glyph, start: column, end });
100          span.append(glyph);
101        }
102        column = end;
103      }
104      if (!run.wide) span.textContent = run.text;
105      line.runs.push({ run, element: span, start, end: column });
106      return span;
107    });
108    line.element.replaceChildren(...(spans.length ? spans : [document.createElement("br")]));
109    this.#position(y);
110  }
111
112  #position(y: number) {
113    const { element, runs, glyphs } = this.#lines[y];
114    const { ratio } = this.#metrics;
115    // keep rows in flow for native line breaks, without accumulating snapped-height rounding
116    element.style.top = `${this.#y(y) / ratio - y * this.#metrics.cell.height}px`;
117    element.style.height = `${this.#metrics.cell.height}px`;
118    element.style.lineHeight = `${(this.#y(y + 1) - this.#y(y)) / ratio}px`;
119    for (const span of [...runs, ...glyphs]) {
120      span.element.style.width = `${(this.#x(span.end) - this.#x(span.start)) / ratio}px`;
121    }
122  }
123
124  #paint() {
125    const ctx = this.#context;
126    if (!ctx) return;
127    const { ratio } = this.#metrics;
128    ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
129    for (let y = 0; y < this.#lines.length; y++) {
130      const top = this.#y(y);
131      const bottom = this.#y(y + 1);
132      for (const { run, start, end } of this.#lines[y].runs) {
133        const left = this.#x(start);
134        const right = this.#x(end);
135        if (run.bg) {
136          ctx.fillStyle = css(run.bg);
137          ctx.fillRect(left, top, right - left, bottom - top);
138        }
139        if (!run.invisible && (run.underline || run.strikethrough)) {
140          ctx.save();
141          ctx.globalAlpha = run.faint ? 0.6 : 1;
142          ctx.strokeStyle = ctx.fillStyle = css(
143            readable(run.fg ?? COLORS.foreground, run.bg ?? COLORS.background),
144          );
145          const thickness = Math.max(1, Math.round(ratio));
146          const baseline = bottom - thickness * 2;
147          if (run.strikethrough) {
148            ctx.fillRect(left, Math.round((top + bottom) / 2), right - left, thickness);
149          }
150          if (run.underline === 3) {
151            ctx.lineWidth = thickness;
152            ctx.beginPath();
153            for (let x = left; x <= right; x++) {
154              const at = baseline + Math.sin((x - left) / (2 * ratio)) * ratio;
155              if (x === left) ctx.moveTo(x, at);
156              else ctx.lineTo(x, at);
157            }
158            ctx.stroke();
159          } else if (run.underline) {
160            const step = run.underline === 4
161              ? thickness * 2
162              : run.underline === 5
163              ? thickness * 6
164              : right - left;
165            const length = run.underline === 4
166              ? thickness
167              : run.underline === 5
168              ? thickness * 4
169              : step;
170            for (let x = left; x < right; x += step) {
171              ctx.fillRect(x, baseline, Math.min(length, right - x), thickness);
172            }
173            if (run.underline === 2) {
174              ctx.fillRect(left, baseline - thickness * 2, right - left, thickness);
175            }
176          }
177          ctx.restore();
178        }
179      }
180    }
181    const at = this.#cursor;
182    if (!at) return;
183    const left = this.#x(at.x);
184    const top = this.#y(at.y);
185    const width = this.#x(at.x + 1) - left;
186    const height = this.#y(at.y + 1) - top;
187    const stroke = Math.max(1, Math.round(ratio));
188    const thick = Math.max(1, Math.round(2 * ratio));
189    ctx.fillStyle = css(COLORS.cursor);
190    if (!this.#focused || at.style === "hollow") {
191      ctx.fillRect(left, top, width, stroke);
192      ctx.fillRect(left, top + height - stroke, width, stroke);
193      ctx.fillRect(left, top, stroke, height);
194      ctx.fillRect(left + width - stroke, top, stroke, height);
195    } else if (at.style === "bar") ctx.fillRect(left, top, thick, height);
196    else if (at.style === "underline") ctx.fillRect(left, top + height - thick, width, thick);
197    else {
198      ctx.globalAlpha = 0.7;
199      ctx.fillRect(left, top, width, height);
200      ctx.globalAlpha = 1;
201    }
202  }
203}