tui-cap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/anim.js +104 -0
- package/dist/animation.js +96 -0
- package/dist/asciinema.js +125 -0
- package/dist/cli.js +648 -0
- package/dist/edits.js +527 -0
- package/dist/fonts.js +87 -0
- package/dist/input.js +136 -0
- package/dist/meta.js +58 -0
- package/dist/palette.js +111 -0
- package/dist/parse.js +659 -0
- package/dist/paths.js +100 -0
- package/dist/record-pty.js +148 -0
- package/dist/record.js +100 -0
- package/dist/server.js +978 -0
- package/dist/svg.js +767 -0
- package/dist/timing.js +112 -0
- package/dist/types.js +2 -0
- package/dist/version.js +232 -0
- package/dist/web/app.js +3312 -0
- package/dist/web/fonts/MonaSansMonoVF-wght.woff2 +0 -0
- package/dist/web/fonts/MonaSansVF-wdth-wght-opsz.woff2 +0 -0
- package/dist/web/fonts/README.md +13 -0
- package/dist/web/index.html +382 -0
- package/dist/web/logo.svg +11 -0
- package/dist/web/styles.css +925 -0
- package/dist/web/timing-model.js +115 -0
- package/dist/web/vendor/mp4-muxer.LICENSE +21 -0
- package/dist/web/vendor/mp4-muxer.js +1885 -0
- package/package.json +61 -0
package/dist/svg.js
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defaultRenderOptions = defaultRenderOptions;
|
|
4
|
+
exports.cellForeground = cellForeground;
|
|
5
|
+
exports.measureGrid = measureGrid;
|
|
6
|
+
exports.renderSvg = renderSvg;
|
|
7
|
+
const palette_1 = require("./palette");
|
|
8
|
+
/** Build a full set of render options, overriding defaults as needed. */
|
|
9
|
+
function defaultRenderOptions(themeName = 'dark', overrides = {}) {
|
|
10
|
+
const chromeStyle = overrides.chromeStyle ?? 'mac';
|
|
11
|
+
return {
|
|
12
|
+
theme: palette_1.THEMES[themeName],
|
|
13
|
+
fontFamily: "'SFMono-Regular', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
|
|
14
|
+
fontSize: 14,
|
|
15
|
+
// SF Mono's true per-column advance: advanceWidth 1266 / unitsPerEm 2048 =
|
|
16
|
+
// 0.618164 em (8.654px at 14px), confirmed against the font file and Figma's
|
|
17
|
+
// own measurement. This MUST be the font's real metric, not a hand-tuned
|
|
18
|
+
// value: renderers that honour `textLength` (Chrome, Illustrator, the raster
|
|
19
|
+
// exporter) flow text to the grid either way, but Figma ignores `textLength`
|
|
20
|
+
// and draws every glyph at the font's true advance — so only a real-metric
|
|
21
|
+
// grid keeps Figma's text aligned with the column-pinned rects and borders.
|
|
22
|
+
advance: 0.618164,
|
|
23
|
+
// Terminals draw rows edge to edge with no inter-line gap; 1.25 keeps block
|
|
24
|
+
// bars and the mascot gapless and matches the default terminal cell height.
|
|
25
|
+
// The GUI exposes a slider and the CLI a --line-height flag to fine-tune.
|
|
26
|
+
lineHeight: 1.25,
|
|
27
|
+
padding: 24,
|
|
28
|
+
chrome: true,
|
|
29
|
+
chromeStyle,
|
|
30
|
+
title: 'GitHub Copilot CLI',
|
|
31
|
+
// Windows 11 title bars round the top corners at 7px (Figma spec); macOS
|
|
32
|
+
// Tahoe rounds at ~20px (corner radius ≈ half the title-bar height, measured
|
|
33
|
+
// off a real Terminal).
|
|
34
|
+
radius: chromeStyle === 'windows' || chromeStyle === 'windows-inactive' ? 7 : 20,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A single glyph in the Unicode Box Drawing block (U+2500–U+257F): the rules,
|
|
40
|
+
* borders and rounded corners (╭ ╮ ╰ ╯ │ ─ ├ ┤ …) that frame dialogs and
|
|
41
|
+
* sub-menus. Every such glyph is broken into its own absolutely-positioned run
|
|
42
|
+
* (see `buildRuns`) so a box border lands on its exact grid column no matter how
|
|
43
|
+
* a renderer distributes `textLength` spacing — see the comment there for why.
|
|
44
|
+
*/
|
|
45
|
+
const BOX_DRAWING = /^[\u2500-\u257F]$/;
|
|
46
|
+
const EMOJI_CAPABLE = /\p{Emoji}/u;
|
|
47
|
+
const EMOJI_DEFAULT = /\p{Emoji_Presentation}/u;
|
|
48
|
+
/**
|
|
49
|
+
* Pin emoji-capable-but-text-default glyphs (⚠ ✔ ▶ ™ …) to TEXT presentation by
|
|
50
|
+
* appending VS15 (U+FE0E). The terminal rendered them as a single-cell monochrome
|
|
51
|
+
* glyph; Illustrator, Chrome and other SVG renderers otherwise substitute a colour
|
|
52
|
+
* emoji, and VS15 forces the monochrome text glyph back for them.
|
|
53
|
+
*
|
|
54
|
+
* NOTE: Figma is the exception — its text engine force-renders U+26A0-class code
|
|
55
|
+
* points as colour emoji and ignores both VS15 and the assigned font (verified via
|
|
56
|
+
* figma.createNodeFromSvg + screenshot across Menlo/Apple Symbols/Arial Unicode MS/
|
|
57
|
+
* Segoe UI Symbol). There is no pure-SVG override; the Figma-native export tracked
|
|
58
|
+
* as a deferred follow-up is the fix there. We still emit VS15 because it is correct
|
|
59
|
+
* for the SVG's primary targets (Illustrator + raster) and is inert in Figma.
|
|
60
|
+
*
|
|
61
|
+
* Scoped to non-ASCII code points so characters that merely carry the Emoji property
|
|
62
|
+
* (digits, `#`, `*`) are left alone, skipped for genuinely wide / emoji-presentation
|
|
63
|
+
* glyphs (🚀), and a no-op when a variation selector already trails the base char.
|
|
64
|
+
*/
|
|
65
|
+
function pinTextPresentation(char, width) {
|
|
66
|
+
if (width === 1 &&
|
|
67
|
+
char.length === 1 &&
|
|
68
|
+
char.codePointAt(0) >= 0x2000 &&
|
|
69
|
+
EMOJI_CAPABLE.test(char) &&
|
|
70
|
+
!EMOJI_DEFAULT.test(char)) {
|
|
71
|
+
return `${char}\uFE0E`;
|
|
72
|
+
}
|
|
73
|
+
return char;
|
|
74
|
+
}
|
|
75
|
+
function isBlank(cell) {
|
|
76
|
+
return (cell.char === ' ' &&
|
|
77
|
+
cell.bg === null &&
|
|
78
|
+
!cell.inverse &&
|
|
79
|
+
!cell.underline &&
|
|
80
|
+
!cell.strike);
|
|
81
|
+
}
|
|
82
|
+
function effective(cell, theme) {
|
|
83
|
+
let fill = cell.fg ?? theme.foreground;
|
|
84
|
+
let bg = cell.bg;
|
|
85
|
+
if (cell.inverse) {
|
|
86
|
+
const newFill = cell.bg ?? theme.background;
|
|
87
|
+
bg = cell.fg ?? theme.foreground;
|
|
88
|
+
fill = newFill;
|
|
89
|
+
}
|
|
90
|
+
const decoration = [cell.underline ? 'underline' : '', cell.strike ? 'line-through' : '']
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join(' ') || null;
|
|
93
|
+
return {
|
|
94
|
+
fill,
|
|
95
|
+
bg,
|
|
96
|
+
weight: cell.bold ? 700 : 400,
|
|
97
|
+
italic: cell.italic,
|
|
98
|
+
decoration,
|
|
99
|
+
underlineStyle: cell.underline ? cell.underlineStyle ?? 'single' : undefined,
|
|
100
|
+
underlineColor: cell.underline ? cell.underlineColor ?? null : null,
|
|
101
|
+
opacity: cell.dim ? 0.6 : 1,
|
|
102
|
+
visible: !cell.invisible,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The resolved foreground (glyph) colour a cell renders with — honouring an
|
|
107
|
+
* explicit fg override, the theme default, and reverse video — as a hex string.
|
|
108
|
+
* Used by the GUI eyedropper to sample a character's on-screen colour.
|
|
109
|
+
*/
|
|
110
|
+
function cellForeground(cell, theme) {
|
|
111
|
+
return effective(cell, theme).fill;
|
|
112
|
+
}
|
|
113
|
+
// Map our underline style names to the CSS `text-decoration-style` keyword.
|
|
114
|
+
// 'single' is the default and is expressed via the plain `text-decoration`
|
|
115
|
+
// presentation attribute instead, so it isn't listed here.
|
|
116
|
+
const CSS_UNDERLINE_STYLE = {
|
|
117
|
+
double: 'double',
|
|
118
|
+
curly: 'wavy',
|
|
119
|
+
dotted: 'dotted',
|
|
120
|
+
dashed: 'dashed',
|
|
121
|
+
};
|
|
122
|
+
const FULL_CELL = [0, 0, 1, 1];
|
|
123
|
+
const BLOCK_GEOMETRY = {
|
|
124
|
+
'\u2580': { rects: [[0, 0, 1, 1 / 2]] }, // ▀ upper half
|
|
125
|
+
'\u2581': { rects: [[0, 7 / 8, 1, 1 / 8]] }, // ▁ lower 1/8
|
|
126
|
+
'\u2582': { rects: [[0, 6 / 8, 1, 2 / 8]] }, // ▂ lower 1/4
|
|
127
|
+
'\u2583': { rects: [[0, 5 / 8, 1, 3 / 8]] }, // ▃ lower 3/8
|
|
128
|
+
'\u2584': { rects: [[0, 1 / 2, 1, 1 / 2]] }, // ▄ lower half
|
|
129
|
+
'\u2585': { rects: [[0, 3 / 8, 1, 5 / 8]] }, // ▅ lower 5/8
|
|
130
|
+
'\u2586': { rects: [[0, 2 / 8, 1, 6 / 8]] }, // ▆ lower 3/4
|
|
131
|
+
'\u2587': { rects: [[0, 1 / 8, 1, 7 / 8]] }, // ▇ lower 7/8
|
|
132
|
+
'\u2588': { rects: [FULL_CELL] }, // █ full block
|
|
133
|
+
'\u2589': { rects: [[0, 0, 7 / 8, 1]] }, // ▉ left 7/8
|
|
134
|
+
'\u258A': { rects: [[0, 0, 6 / 8, 1]] }, // ▊ left 3/4
|
|
135
|
+
'\u258B': { rects: [[0, 0, 5 / 8, 1]] }, // ▋ left 5/8
|
|
136
|
+
'\u258C': { rects: [[0, 0, 1 / 2, 1]] }, // ▌ left half
|
|
137
|
+
'\u258D': { rects: [[0, 0, 3 / 8, 1]] }, // ▍ left 3/8
|
|
138
|
+
'\u258E': { rects: [[0, 0, 2 / 8, 1]] }, // ▎ left 1/4
|
|
139
|
+
'\u258F': { rects: [[0, 0, 1 / 8, 1]] }, // ▏ left 1/8
|
|
140
|
+
'\u2590': { rects: [[1 / 2, 0, 1 / 2, 1]] }, // ▐ right half
|
|
141
|
+
'\u2591': { rects: [FULL_CELL], opacity: 0.25 }, // ░ light shade
|
|
142
|
+
'\u2592': { rects: [FULL_CELL], opacity: 0.5 }, // ▒ medium shade
|
|
143
|
+
'\u2593': { rects: [FULL_CELL], opacity: 0.75 }, // ▓ dark shade
|
|
144
|
+
'\u2594': { rects: [[0, 0, 1, 1 / 8]] }, // ▔ upper 1/8
|
|
145
|
+
'\u2595': { rects: [[7 / 8, 0, 1 / 8, 1]] }, // ▕ right 1/8
|
|
146
|
+
'\u2596': { rects: [[0, 1 / 2, 1 / 2, 1 / 2]] }, // ▖ lower left
|
|
147
|
+
'\u2597': { rects: [[1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▗ lower right
|
|
148
|
+
'\u2598': { rects: [[0, 0, 1 / 2, 1 / 2]] }, // ▘ upper left
|
|
149
|
+
'\u2599': { rects: [[0, 0, 1 / 2, 1], [1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▙
|
|
150
|
+
'\u259A': { rects: [[0, 0, 1 / 2, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▚
|
|
151
|
+
'\u259B': { rects: [[0, 0, 1, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]] }, // ▛
|
|
152
|
+
'\u259C': { rects: [[0, 0, 1, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▜
|
|
153
|
+
'\u259D': { rects: [[1 / 2, 0, 1 / 2, 1 / 2]] }, // ▝ upper right
|
|
154
|
+
'\u259E': { rects: [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]] }, // ▞
|
|
155
|
+
'\u259F': { rects: [[1 / 2, 0, 1 / 2, 1], [0, 1 / 2, 1 / 2, 1 / 2]] }, // ▟
|
|
156
|
+
};
|
|
157
|
+
function sig(r) {
|
|
158
|
+
return [
|
|
159
|
+
r.fill,
|
|
160
|
+
r.bg ?? '_',
|
|
161
|
+
r.weight,
|
|
162
|
+
r.italic ? 'i' : '',
|
|
163
|
+
r.decoration ?? '',
|
|
164
|
+
r.underlineStyle ?? '',
|
|
165
|
+
r.underlineColor ?? '',
|
|
166
|
+
r.opacity,
|
|
167
|
+
r.visible ? 'v' : 'h',
|
|
168
|
+
].join('|');
|
|
169
|
+
}
|
|
170
|
+
function buildRuns(cells, theme) {
|
|
171
|
+
// Trim trailing blank cells so the SVG crops tightly to content.
|
|
172
|
+
let end = cells.length;
|
|
173
|
+
while (end > 0 && isBlank(cells[end - 1]))
|
|
174
|
+
end--;
|
|
175
|
+
const runs = [];
|
|
176
|
+
let current = null;
|
|
177
|
+
for (let i = 0; i < end; i++) {
|
|
178
|
+
const cell = cells[i];
|
|
179
|
+
const e = effective(cell, theme);
|
|
180
|
+
const wide = cell.width >= 2;
|
|
181
|
+
const block = BLOCK_GEOMETRY[cell.char] !== undefined;
|
|
182
|
+
const box = BOX_DRAWING.test(cell.char);
|
|
183
|
+
const char = block ? cell.char : pinTextPresentation(cell.char, cell.width);
|
|
184
|
+
const next = {
|
|
185
|
+
startCol: cell.col,
|
|
186
|
+
widthCols: cell.width,
|
|
187
|
+
text: char,
|
|
188
|
+
...e,
|
|
189
|
+
wide,
|
|
190
|
+
block,
|
|
191
|
+
box,
|
|
192
|
+
};
|
|
193
|
+
// A double-width cell, or a block-element cell drawn as geometry, must stand
|
|
194
|
+
// alone. Every run is absolutely positioned by an explicit `x`, but inside a
|
|
195
|
+
// run the font advances each glyph by its own (single-column) advance.
|
|
196
|
+
// Merging a wide glyph would push the remainder of the run one column left
|
|
197
|
+
// per wide cell; block cells are not emitted as text at all. Breaking the
|
|
198
|
+
// run here — and forbidding anything from merging onto a wide/block run —
|
|
199
|
+
// keeps every following run pinned to its true column.
|
|
200
|
+
//
|
|
201
|
+
// Box-drawing glyphs (borders, rules, the rounded corners of dialog and
|
|
202
|
+
// sub-menu frames) are isolated for a related reason: a box border is
|
|
203
|
+
// reached on different rows by runs of very different length — a single `│`
|
|
204
|
+
// on a body row, a 3-glyph `│ │` on an input-box row, a 100+-glyph
|
|
205
|
+
// `│ ╭───╮ │` on a frame edge. `lengthAdjust="spacing"` distributes the
|
|
206
|
+
// gap between our cell width and a renderer's (often wider, substituted)
|
|
207
|
+
// monospace advance across each run independently, so the SAME logical
|
|
208
|
+
// column lands at slightly different x per run length and the border
|
|
209
|
+
// zig-zags / doubles in Figma. Pinning every box glyph to its own
|
|
210
|
+
// single-glyph run gives each its own absolute `x`, so borders, corners and
|
|
211
|
+
// rules align column-perfectly in every renderer while keeping the exact
|
|
212
|
+
// font glyph (rounded corners included).
|
|
213
|
+
const mergeable = current !== null &&
|
|
214
|
+
!wide &&
|
|
215
|
+
!block &&
|
|
216
|
+
!box &&
|
|
217
|
+
!current.wide &&
|
|
218
|
+
!current.block &&
|
|
219
|
+
!current.box &&
|
|
220
|
+
sig(current) === sig(next) &&
|
|
221
|
+
cell.col === current.startCol + current.widthCols;
|
|
222
|
+
if (mergeable) {
|
|
223
|
+
current.text += char;
|
|
224
|
+
current.widthCols += cell.width;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
if (current)
|
|
228
|
+
runs.push(current);
|
|
229
|
+
current = next;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (current)
|
|
233
|
+
runs.push(current);
|
|
234
|
+
// The emit loop groups column-contiguous foreground runs (text + the single
|
|
235
|
+
// spaces between them) into one <text> per stretch, so prose stays a single
|
|
236
|
+
// editable object and Figma flows colour changes seamlessly. Here we only need
|
|
237
|
+
// to break a run at interior gaps of 2+ spaces — which every SVG renderer
|
|
238
|
+
// collapses to one space — so the segment after the gap starts its own run and
|
|
239
|
+
// pins to its true column (a dialog box's interior padding can't pull its right
|
|
240
|
+
// border left). Leading whitespace is peeled into its own run so the first
|
|
241
|
+
// glyph keeps its own absolute x even though Figma trims a node's leading
|
|
242
|
+
// whitespace.
|
|
243
|
+
return runs
|
|
244
|
+
.flatMap((run) => splitAtInteriorGaps(run))
|
|
245
|
+
.flatMap(pinAfterLeadingSpace);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Split a run at interior gaps of two or more spaces so each visible segment is
|
|
249
|
+
* pinned to its own column.
|
|
250
|
+
*
|
|
251
|
+
* A run is positioned only by an explicit `x` on its first cell; the font then
|
|
252
|
+
* advances each later glyph itself. SVG renderers (Figma, headless Chrome, many
|
|
253
|
+
* editor previews) collapse a run of 2+ spaces to a single space, so a row that
|
|
254
|
+
* is one uniform-style run — a dialog/sub-menu box row like
|
|
255
|
+
* "│ Prompt: │" or a selected menu item "❯ 1. Yes " — loses its
|
|
256
|
+
* interior padding and its trailing border/segment slides left. Pinning such a
|
|
257
|
+
* run's end with `textLength` would instead stretch the few surviving glyphs
|
|
258
|
+
* across the whole row. Splitting the run at every 2+-space gap gives each
|
|
259
|
+
* visible segment (a border, a label, a nested border) its own absolute `x` on
|
|
260
|
+
* its true column, so it lands correctly regardless of how the renderer treats
|
|
261
|
+
* whitespace, and each segment is short and gap-free so `textLength` pins it
|
|
262
|
+
* without stretching.
|
|
263
|
+
*
|
|
264
|
+
* The split keeps full column coverage: the gap stretches are emitted as their
|
|
265
|
+
* own space-only sub-runs (skipped by the text pass but still drawn as
|
|
266
|
+
* background rects), so selection bars and other backgrounds tile exactly as
|
|
267
|
+
* before. Single spaces are never a gap, so ordinary words ("GitHub Copilot
|
|
268
|
+
* CLI", "❯ ok") stay one run and flow together in the emit loop. Wide/block/box
|
|
269
|
+
* runs are always a single cell and are never split. Column offsets are counted
|
|
270
|
+
* excluding VS15 (U+FE0E), which `pinTextPresentation` appends as a zero-width
|
|
271
|
+
* selector.
|
|
272
|
+
*/
|
|
273
|
+
function splitAtInteriorGaps(run) {
|
|
274
|
+
if (run.wide || run.block || run.box)
|
|
275
|
+
return [run];
|
|
276
|
+
if (!/ {2,}/.test(run.text))
|
|
277
|
+
return [run];
|
|
278
|
+
const text = run.text;
|
|
279
|
+
const colsOf = (s) => {
|
|
280
|
+
let n = 0;
|
|
281
|
+
for (const cp of s)
|
|
282
|
+
if (cp !== '\uFE0E')
|
|
283
|
+
n++;
|
|
284
|
+
return n;
|
|
285
|
+
};
|
|
286
|
+
const parts = [];
|
|
287
|
+
let startCol = run.startCol;
|
|
288
|
+
const emit = (slice) => {
|
|
289
|
+
if (slice === '')
|
|
290
|
+
return;
|
|
291
|
+
const widthCols = colsOf(slice);
|
|
292
|
+
parts.push({ ...run, text: slice, startCol, widthCols });
|
|
293
|
+
startCol += widthCols;
|
|
294
|
+
};
|
|
295
|
+
const gap = / {2,}/g;
|
|
296
|
+
let cursor = 0;
|
|
297
|
+
let m;
|
|
298
|
+
while ((m = gap.exec(text)) !== null) {
|
|
299
|
+
emit(text.slice(cursor, m.index));
|
|
300
|
+
emit(m[0]);
|
|
301
|
+
cursor = m.index + m[0].length;
|
|
302
|
+
}
|
|
303
|
+
emit(text.slice(cursor));
|
|
304
|
+
return parts;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Pin a glyph that follows a gap to its true column.
|
|
308
|
+
*
|
|
309
|
+
* A run is positioned only by an explicit `x` on its first cell; the font then
|
|
310
|
+
* advances every later glyph by its own width. A run that is a long stretch of
|
|
311
|
+
* spaces followed by a visible glyph — the right-edge box border / scrollbar,
|
|
312
|
+
* which shares the white fill of the empty cells before it, so it merges into
|
|
313
|
+
* the same run — therefore lets that glyph ride N space-advances past the run's
|
|
314
|
+
* `x`. In any renderer whose space advance differs from our cell width by δ
|
|
315
|
+
* (Figma and some editor SVG previews substitute the monospace font) the glyph
|
|
316
|
+
* drifts by N·δ, so the right border zig-zags row to row: pinned on rows where
|
|
317
|
+
* its colour breaks the run, drifted on rows where it doesn't.
|
|
318
|
+
*
|
|
319
|
+
* Splitting the leading whitespace into its own run gives the trailing glyph its
|
|
320
|
+
* own absolute `x`, so it lands on its true column in every renderer. Both halves
|
|
321
|
+
* keep the same style (and background), so bars and gaps are unchanged. Runs with
|
|
322
|
+
* no leading whitespace (ordinary words like "GitHub Copilot CLI") and pure
|
|
323
|
+
* whitespace runs (separators/indents) are returned untouched, and wide/block
|
|
324
|
+
* runs — always a single cell — are never split.
|
|
325
|
+
*/
|
|
326
|
+
function pinAfterLeadingSpace(run) {
|
|
327
|
+
if (run.wide || run.block || run.box)
|
|
328
|
+
return [run];
|
|
329
|
+
const lead = run.text.length - run.text.replace(/^ +/, '').length;
|
|
330
|
+
if (lead === 0 || lead === run.text.length)
|
|
331
|
+
return [run];
|
|
332
|
+
return [
|
|
333
|
+
{ ...run, text: run.text.slice(0, lead), widthCols: lead },
|
|
334
|
+
{
|
|
335
|
+
...run,
|
|
336
|
+
text: run.text.slice(lead),
|
|
337
|
+
startCol: run.startCol + lead,
|
|
338
|
+
widthCols: run.widthCols - lead,
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
function escapeXml(s) {
|
|
343
|
+
return s
|
|
344
|
+
.replace(/&/g, '&')
|
|
345
|
+
.replace(/</g, '<')
|
|
346
|
+
.replace(/>/g, '>')
|
|
347
|
+
.replace(/"/g, '"');
|
|
348
|
+
}
|
|
349
|
+
function roundedTopRect(w, h, r) {
|
|
350
|
+
return `M0,${h} L0,${r} Q0,0 ${r},0 L${w - r},0 Q${w},0 ${w},${r} L${w},${h} Z`;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Windows 11 caption buttons (minimize, maximize, close) drawn as line/rect
|
|
354
|
+
* glyphs flush to the right edge of the title bar. Each button occupies a
|
|
355
|
+
* 46px-wide cell (the Windows DIP convention); only the glyphs are drawn so the
|
|
356
|
+
* bar reads as "at rest" with no hover backgrounds.
|
|
357
|
+
*/
|
|
358
|
+
function winCaptionButtons(width, cy, fg, opacity) {
|
|
359
|
+
const capW = 46;
|
|
360
|
+
const g = 5; // half glyph size (10px icons)
|
|
361
|
+
const s = `stroke="${fg}" stroke-opacity="${opacity}" stroke-width="1" fill="none" shape-rendering="geometricPrecision"`;
|
|
362
|
+
const closeCx = width - capW / 2;
|
|
363
|
+
const maxCx = width - capW * 1.5;
|
|
364
|
+
const minCx = width - capW * 2.5;
|
|
365
|
+
return (`<line x1="${minCx - g}" y1="${cy}" x2="${minCx + g}" y2="${cy}" ${s}/>` +
|
|
366
|
+
`<rect x="${maxCx - g}" y="${cy - g}" width="${g * 2}" height="${g * 2}" rx="1" ${s}/>` +
|
|
367
|
+
`<line x1="${closeCx - g}" y1="${cy - g}" x2="${closeCx + g}" y2="${cy + g}" ${s}/>` +
|
|
368
|
+
`<line x1="${closeCx + g}" y1="${cy - g}" x2="${closeCx - g}" y2="${cy + g}" ${s}/>`);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The content extent of a set of per-row runs, in grid columns/rows: the
|
|
372
|
+
* right-most occupied column and the bottom-most occupied row (each at least 1).
|
|
373
|
+
* Shared by `renderSvg` (auto-fit) and `measureGrid` so a measured size always
|
|
374
|
+
* matches what `renderSvg` would draw.
|
|
375
|
+
*/
|
|
376
|
+
function measureRuns(runsByRow) {
|
|
377
|
+
let rows = 0;
|
|
378
|
+
let cols = 1;
|
|
379
|
+
runsByRow.forEach((runs, y) => {
|
|
380
|
+
if (runs.length === 0)
|
|
381
|
+
return;
|
|
382
|
+
rows = y + 1;
|
|
383
|
+
const last = runs[runs.length - 1];
|
|
384
|
+
cols = Math.max(cols, last.startCol + last.widthCols);
|
|
385
|
+
});
|
|
386
|
+
return { cols, rows: Math.max(rows, 1) };
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Measure a grid's content extent (columns × rows) exactly as `renderSvg` would
|
|
390
|
+
* auto-fit it. Used to compute a uniform animation canvas size across frames.
|
|
391
|
+
*/
|
|
392
|
+
function measureGrid(grid, theme) {
|
|
393
|
+
return measureRuns(grid.lines.map((cells) => buildRuns(cells, theme)));
|
|
394
|
+
}
|
|
395
|
+
/** Render a styled grid into a self-contained, editable SVG document. */
|
|
396
|
+
function renderSvg(grid, opts) {
|
|
397
|
+
const { theme } = opts;
|
|
398
|
+
const cellW = opts.fontSize * opts.advance;
|
|
399
|
+
const cellH = Math.round(opts.fontSize * opts.lineHeight);
|
|
400
|
+
const runsByRow = grid.lines.map((cells) => buildRuns(cells, theme));
|
|
401
|
+
// Auto-fit canvas to the content actually present.
|
|
402
|
+
const fit = measureRuns(runsByRow);
|
|
403
|
+
// For uniform animation frames the caller can pin a minimum canvas size so
|
|
404
|
+
// every frame shares identical dimensions; real content never gets clipped.
|
|
405
|
+
const usedCols = Math.max(fit.cols, opts.fixedCols ?? 0);
|
|
406
|
+
const usedRows = Math.max(fit.rows, opts.fixedRows ?? 0);
|
|
407
|
+
// A typing cursor can sit one cell past the last glyph (an empty cell that
|
|
408
|
+
// background trimming has already dropped), so widen the canvas to keep it on
|
|
409
|
+
// screen. Clamp to a sane cell so a stray caret can't blow up the dimensions.
|
|
410
|
+
const cursor = opts.cursor && opts.cursor.row >= 0 && opts.cursor.col >= 0 ? opts.cursor : null;
|
|
411
|
+
const canvasCols = cursor ? Math.max(usedCols, cursor.col + 1) : usedCols;
|
|
412
|
+
const canvasRows = cursor ? Math.max(usedRows, cursor.row + 1) : usedRows;
|
|
413
|
+
const isWindows = opts.chromeStyle === 'windows' || opts.chromeStyle === 'windows-inactive';
|
|
414
|
+
const barH = opts.chrome ? (isWindows ? 32 : 40) : 0;
|
|
415
|
+
const contentLeft = opts.padding;
|
|
416
|
+
const contentTop = barH + opts.padding;
|
|
417
|
+
const width = Math.ceil(canvasCols * cellW + opts.padding * 2);
|
|
418
|
+
const height = Math.ceil(canvasRows * cellH + opts.padding * 2 + barH);
|
|
419
|
+
const x = (col) => +(contentLeft + col * cellW).toFixed(2);
|
|
420
|
+
const lineTop = (row) => contentTop + row * cellH;
|
|
421
|
+
const baseline = (row) => +(lineTop(row) + opts.fontSize).toFixed(2);
|
|
422
|
+
// Emit a block-element glyph as one or more geometric rects filling the cell,
|
|
423
|
+
// so it tiles edge to edge instead of leaving a line-height gap.
|
|
424
|
+
const blockRectsFor = (char, col, row, fill, opacity) => {
|
|
425
|
+
const geom = BLOCK_GEOMETRY[char];
|
|
426
|
+
if (!geom)
|
|
427
|
+
return [];
|
|
428
|
+
const cellLeft = x(col);
|
|
429
|
+
const cellRight = x(col + 1);
|
|
430
|
+
const cellWpx = cellRight - cellLeft;
|
|
431
|
+
const cellTop = lineTop(row);
|
|
432
|
+
const op = opacity * (geom.opacity ?? 1);
|
|
433
|
+
const opAttr = op !== 1 ? ` fill-opacity="${+op.toFixed(3)}"` : '';
|
|
434
|
+
return geom.rects.map(([fx, fy, fw, fh]) => {
|
|
435
|
+
const rx = +(cellLeft + cellWpx * fx).toFixed(2);
|
|
436
|
+
const ry = +(cellTop + cellH * fy).toFixed(2);
|
|
437
|
+
const rw = +(cellWpx * fw).toFixed(2);
|
|
438
|
+
const rh = +(cellH * fh).toFixed(2);
|
|
439
|
+
return (`<rect x="${rx}" y="${ry}" width="${rw}" height="${rh}" ` +
|
|
440
|
+
`fill="${fill}"${opAttr} shape-rendering="crispEdges"/>`);
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
const bgRects = [];
|
|
444
|
+
const blockRects = [];
|
|
445
|
+
const textLines = [];
|
|
446
|
+
runsByRow.forEach((runs, y) => {
|
|
447
|
+
if (runs.length === 0)
|
|
448
|
+
return;
|
|
449
|
+
for (const run of runs) {
|
|
450
|
+
if (run.bg) {
|
|
451
|
+
bgRects.push(`<rect x="${x(run.startCol)}" y="${lineTop(y)}" ` +
|
|
452
|
+
`width="${+(run.widthCols * cellW).toFixed(2)}" height="${cellH}" ` +
|
|
453
|
+
`fill="${run.bg}" shape-rendering="crispEdges"/>`);
|
|
454
|
+
}
|
|
455
|
+
// Block-element glyphs are drawn geometrically (gapless), on top of any bg.
|
|
456
|
+
if (run.block && run.visible) {
|
|
457
|
+
blockRects.push(...blockRectsFor(run.text, run.startCol, y, run.fill, run.opacity));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Emit the row's text by grouping each run of column-contiguous foreground
|
|
461
|
+
// glyphs into ONE <text> element, with a <tspan> per colour/style segment.
|
|
462
|
+
//
|
|
463
|
+
// Figma's SVG import honours `x` on a <text> but *ignores* it on a child
|
|
464
|
+
// <tspan>: it flows a text node's tspans continuously at the font's own
|
|
465
|
+
// advance. Our grid uses SF Mono's true advance (see `advance`), so that flow
|
|
466
|
+
// lands on the same columns as the background rects and borders. A SINGLE
|
|
467
|
+
// <text> per contiguous stretch is therefore the faithful unit: the single
|
|
468
|
+
// spaces between words are real glyphs that flow (never collapse), a colour
|
|
469
|
+
// change is just the next tspan picking up where the last left off (so a long
|
|
470
|
+
// link can't overflow onto an adjacent period — the "Capture." overlap), and
|
|
471
|
+
// the whole sentence stays ONE editable object instead of a litter of
|
|
472
|
+
// per-word nodes. We only START A NEW <text> where the
|
|
473
|
+
// glyphs are genuinely discontiguous and must pin to their own column:
|
|
474
|
+
// • box / wide / block glyphs — each owns its cell (borders must tile, a
|
|
475
|
+
// wide glyph spans two columns), so they are emitted standalone;
|
|
476
|
+
// • background runs (selection bars, tab pills) — kept whole so their fill
|
|
477
|
+
// <rect> tiles as one span;
|
|
478
|
+
// • a real gap of 2+ spaces — collapsed to one space by Figma/Chrome, so it
|
|
479
|
+
// is dropped and the following segment pins to its true column (without
|
|
480
|
+
// this a box's interior padding would pull its right border left).
|
|
481
|
+
// Pure-whitespace runs carry no glyph (any background is a <rect>); a single
|
|
482
|
+
// space *between* two segments flows inside the group, but a leading/trailing
|
|
483
|
+
// one is dropped so the group still begins on its true column (Figma trims a
|
|
484
|
+
// node's leading whitespace).
|
|
485
|
+
const styleAttrs = (run) => {
|
|
486
|
+
const a = [`fill="${run.fill}"`];
|
|
487
|
+
if (run.weight !== 400)
|
|
488
|
+
a.push(`font-weight="${run.weight}"`);
|
|
489
|
+
if (run.italic)
|
|
490
|
+
a.push('font-style="italic"');
|
|
491
|
+
if (run.decoration) {
|
|
492
|
+
// A non-single style or a distinct underline color needs the CSS
|
|
493
|
+
// longhands (the `text-decoration` shorthand attribute can't carry
|
|
494
|
+
// them). Plain single underline / strike stay on the simple, widely
|
|
495
|
+
// supported presentation attribute.
|
|
496
|
+
const styleKey = run.underlineStyle ? CSS_UNDERLINE_STYLE[run.underlineStyle] : undefined;
|
|
497
|
+
if (styleKey || run.underlineColor) {
|
|
498
|
+
const parts = [`text-decoration-line:${run.decoration}`];
|
|
499
|
+
if (styleKey)
|
|
500
|
+
parts.push(`text-decoration-style:${styleKey}`);
|
|
501
|
+
if (run.underlineColor)
|
|
502
|
+
parts.push(`text-decoration-color:${run.underlineColor}`);
|
|
503
|
+
a.push(`style="${parts.join(';')}"`);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
a.push(`text-decoration="${run.decoration}"`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (run.opacity !== 1)
|
|
510
|
+
a.push(`fill-opacity="${run.opacity}"`);
|
|
511
|
+
return a;
|
|
512
|
+
};
|
|
513
|
+
const isStandalone = (run) => run.wide || run.block || run.box || !!run.bg;
|
|
514
|
+
const isBlankRun = (run) => run.text.trim() === '';
|
|
515
|
+
// The font *face* Figma resolves for a run. Figma's SVG import assigns ONE
|
|
516
|
+
// fontName (family + weight + italic) per <text> node — it honours per-<tspan>
|
|
517
|
+
// `fill` (so colour mixes inside one node) but IGNORES per-<tspan> weight and
|
|
518
|
+
// style, so a mixed-weight line collapses to a single weight. The family is
|
|
519
|
+
// global here, so weight+italic is the whole key; a change in it must split
|
|
520
|
+
// into a new pinned <text> (see the grouping loop), while colour/decoration
|
|
521
|
+
// stay as <tspan>s within one node.
|
|
522
|
+
const fontFace = (run) => `${run.weight}|${run.italic ? 'i' : ''}`;
|
|
523
|
+
// Emit a standalone run (border / wide glyph / selection bar) as its own
|
|
524
|
+
// column-pinned <text>, its end pinned to its true column with textLength so
|
|
525
|
+
// it can't ride the substitute font's advance and drift the frame.
|
|
526
|
+
const emitStandalone = (run) => {
|
|
527
|
+
const text = run.wide ? run.text : run.text.replace(/ +$/, '');
|
|
528
|
+
if (text.trim() === '')
|
|
529
|
+
return;
|
|
530
|
+
const widthCols = run.widthCols - (run.text.length - text.length);
|
|
531
|
+
const attrs = [
|
|
532
|
+
`x="${x(run.startCol)}"`,
|
|
533
|
+
`y="${baseline(y)}"`,
|
|
534
|
+
...styleAttrs(run),
|
|
535
|
+
`textLength="${+(widthCols * cellW).toFixed(2)}"`,
|
|
536
|
+
`lengthAdjust="${run.wide ? 'spacingAndGlyphs' : 'spacing'}"`,
|
|
537
|
+
];
|
|
538
|
+
textLines.push(`<text ${attrs.join(' ')}>${escapeXml(text)}</text>`);
|
|
539
|
+
};
|
|
540
|
+
// Emit a contiguous group as one <text>, coalescing adjacent same-style runs
|
|
541
|
+
// into a single tspan (so a uniform sentence is one plain <text>, and a
|
|
542
|
+
// multi-colour line is one <text> with a tspan per colour). textLength pins
|
|
543
|
+
// the group's end to its column span for renderers that honour it (Chrome,
|
|
544
|
+
// the still/MP4 rasteriser); Figma ignores it but flows the tspans, which is
|
|
545
|
+
// what keeps the colour boundaries seamless there.
|
|
546
|
+
const emitGroup = (group) => {
|
|
547
|
+
const segs = [];
|
|
548
|
+
for (const run of group) {
|
|
549
|
+
const attrs = styleAttrs(run);
|
|
550
|
+
const key = attrs.join(' ');
|
|
551
|
+
const last = segs[segs.length - 1];
|
|
552
|
+
if (last && last.key === key) {
|
|
553
|
+
last.text += run.text;
|
|
554
|
+
last.cols += run.widthCols;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
segs.push({ attrs, key, text: run.text, cols: run.widthCols });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Rebalance whitespace at <tspan> boundaries. Figma preserves a trailing
|
|
561
|
+
// space on a tspan (verified) but TRIMS a leading or pure-whitespace one —
|
|
562
|
+
// so a single space that lands at the START of a tspan vanishes there. This
|
|
563
|
+
// bites the leading-icon rows: a coloured bullet "●", a "$" prompt, or a
|
|
564
|
+
// "·" separator is its own colour, the following space is the default
|
|
565
|
+
// colour, and the body is yet another colour, so the space becomes a
|
|
566
|
+
// standalone `<tspan> </tspan>` that Figma drops ("●No copilot…"). Move each
|
|
567
|
+
// segment's leading whitespace onto the previous segment's trailing edge,
|
|
568
|
+
// where Figma keeps it; spaces draw nothing so the colour they carry is
|
|
569
|
+
// irrelevant, and the concatenated text (and textLength) is unchanged, so
|
|
570
|
+
// Chrome/raster render identically. When removing a differently-styled space
|
|
571
|
+
// makes two same-style segments adjacent (e.g. a grey path, a default-colour
|
|
572
|
+
// space, then a grey "[⎇ main]"), re-coalesce them into one tspan so the
|
|
573
|
+
// output stays minimal.
|
|
574
|
+
const balanced = [];
|
|
575
|
+
for (const seg of segs) {
|
|
576
|
+
const lead = seg.text.length - seg.text.replace(/^ +/, '').length;
|
|
577
|
+
if (lead > 0 && balanced.length > 0) {
|
|
578
|
+
const prev = balanced[balanced.length - 1];
|
|
579
|
+
prev.text += seg.text.slice(0, lead);
|
|
580
|
+
prev.cols += lead;
|
|
581
|
+
seg.text = seg.text.slice(lead);
|
|
582
|
+
seg.cols -= lead;
|
|
583
|
+
}
|
|
584
|
+
if (seg.text.length === 0)
|
|
585
|
+
continue;
|
|
586
|
+
const prev = balanced[balanced.length - 1];
|
|
587
|
+
if (prev && prev.key === seg.key) {
|
|
588
|
+
prev.text += seg.text;
|
|
589
|
+
prev.cols += seg.cols;
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
balanced.push(seg);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
segs.length = 0;
|
|
596
|
+
segs.push(...balanced);
|
|
597
|
+
if (segs.length === 0)
|
|
598
|
+
return;
|
|
599
|
+
// Trailing spaces draw nothing and Figma trims them; drop them from the
|
|
600
|
+
// last segment so they don't pad textLength.
|
|
601
|
+
const last = segs[segs.length - 1];
|
|
602
|
+
const trimmed = last.text.replace(/ +$/, '');
|
|
603
|
+
last.cols -= last.text.length - trimmed.length;
|
|
604
|
+
last.text = trimmed;
|
|
605
|
+
if (last.text === '')
|
|
606
|
+
segs.pop();
|
|
607
|
+
if (segs.length === 0)
|
|
608
|
+
return;
|
|
609
|
+
// Figma trims a text node's LEADING whitespace, so the first glyph must own
|
|
610
|
+
// the pin column. A group that began right after a weight split can start on
|
|
611
|
+
// the separating space (it took the new weight); drop that leading space and
|
|
612
|
+
// advance the pin. The empty columns are reclaimed by this `x` pin, and
|
|
613
|
+
// textLength shrinks to match, so Chrome/raster stay pixel-identical.
|
|
614
|
+
let pinCol = group[0].startCol;
|
|
615
|
+
const lead0 = segs[0].text.length - segs[0].text.replace(/^ +/, '').length;
|
|
616
|
+
if (lead0 > 0) {
|
|
617
|
+
segs[0].text = segs[0].text.slice(lead0);
|
|
618
|
+
segs[0].cols -= lead0;
|
|
619
|
+
pinCol += lead0;
|
|
620
|
+
if (segs[0].text === '') {
|
|
621
|
+
segs.shift();
|
|
622
|
+
if (segs.length === 0)
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const totalCols = segs.reduce((n, s) => n + s.cols, 0);
|
|
627
|
+
const head = [
|
|
628
|
+
`x="${x(pinCol)}"`,
|
|
629
|
+
`y="${baseline(y)}"`,
|
|
630
|
+
`textLength="${+(totalCols * cellW).toFixed(2)}"`,
|
|
631
|
+
`lengthAdjust="spacing"`,
|
|
632
|
+
];
|
|
633
|
+
if (segs.length === 1) {
|
|
634
|
+
const attrs = [head[0], head[1], ...segs[0].attrs, head[2], head[3]];
|
|
635
|
+
textLines.push(`<text ${attrs.join(' ')}>${escapeXml(segs[0].text)}</text>`);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
const body = segs
|
|
639
|
+
.map((s) => `<tspan ${s.attrs.join(' ')}>${escapeXml(s.text)}</tspan>`)
|
|
640
|
+
.join('');
|
|
641
|
+
textLines.push(`<text ${[`fill="${group[0].fill}"`, ...head].join(' ')}>${body}</text>`);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
for (let i = 0; i < runs.length;) {
|
|
645
|
+
const run = runs[i];
|
|
646
|
+
if (!run.visible || run.block) {
|
|
647
|
+
i++;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (isStandalone(run)) {
|
|
651
|
+
emitStandalone(run);
|
|
652
|
+
i++;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (isBlankRun(run)) {
|
|
656
|
+
// A separator / indent / 2+-space gap between groups: carries no glyph,
|
|
657
|
+
// and the next group pins itself, so drop it.
|
|
658
|
+
i++;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
// Start a contiguous group at this glyph run and pull in the following
|
|
662
|
+
// flow runs (more glyphs, and the single spaces between them) until a
|
|
663
|
+
// standalone glyph, a 2+-space gap, a weight/italic change, or the end of
|
|
664
|
+
// the row. A weight/italic change must break the group because Figma can't
|
|
665
|
+
// render mixed weight inside one <text> (see `fontFace`); the single space
|
|
666
|
+
// at such a boundary is weight-neutral and rides along, its column reclaimed
|
|
667
|
+
// by the next group's `x` pin.
|
|
668
|
+
const group = [run];
|
|
669
|
+
const groupFace = fontFace(run);
|
|
670
|
+
i++;
|
|
671
|
+
while (i < runs.length) {
|
|
672
|
+
const cur = runs[i];
|
|
673
|
+
if (!cur.visible || cur.block || isStandalone(cur))
|
|
674
|
+
break;
|
|
675
|
+
if (isBlankRun(cur) && cur.widthCols >= 2)
|
|
676
|
+
break;
|
|
677
|
+
if (!isBlankRun(cur) && fontFace(cur) !== groupFace)
|
|
678
|
+
break;
|
|
679
|
+
group.push(cur);
|
|
680
|
+
i++;
|
|
681
|
+
}
|
|
682
|
+
while (group.length && isBlankRun(group[group.length - 1]))
|
|
683
|
+
group.pop();
|
|
684
|
+
if (group.length)
|
|
685
|
+
emitGroup(group);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
const cy = barH / 2;
|
|
689
|
+
// The typing cursor: a solid foreground block at the caret cell, drawn on top
|
|
690
|
+
// of the row's text so it reads as a real block caret.
|
|
691
|
+
const cursorRects = cursor
|
|
692
|
+
? blockRectsFor('\u2588', cursor.col, cursor.row, theme.foreground, 1)
|
|
693
|
+
: [];
|
|
694
|
+
// Window controls: macOS traffic lights (left) or Windows caption buttons (right).
|
|
695
|
+
// Traffic-light geometry measured off a real macOS Tahoe Terminal and scaled to
|
|
696
|
+
// the 40px bar: Ø18 lights, 29px center-to-center, first light inset 20px,
|
|
697
|
+
// vertically centered (cy). Decoupled from content padding so they hug the corner.
|
|
698
|
+
const lightR = 9;
|
|
699
|
+
const lightGap = 29;
|
|
700
|
+
const lightInset = 20;
|
|
701
|
+
// macOS traffic-light fills. On an inactive (unfocused) window macOS drops the
|
|
702
|
+
// red/yellow/green and draws all three as the same flat grey (#464a4c,
|
|
703
|
+
// measured off a real Terminal screenshot).
|
|
704
|
+
const macLights = opts.chromeStyle === 'mac-inactive'
|
|
705
|
+
? ['#464a4c', '#464a4c', '#464a4c']
|
|
706
|
+
: ['#ff5f57', '#febc2e', '#28c840'];
|
|
707
|
+
// Windows 11 title bar (Figma "SVG Import Diagnostic" nodes 52:22797 active /
|
|
708
|
+
// 52:22744 inactive): #202020 fill, rgba(117,117,117,0.4) border, white title
|
|
709
|
+
// + caption glyphs. An inactive (unfocused) window dims the title and glyphs to
|
|
710
|
+
// 36% white; the fill and border are unchanged.
|
|
711
|
+
const winInactive = opts.chromeStyle === 'windows-inactive';
|
|
712
|
+
const winFg = '#ffffff';
|
|
713
|
+
const winOpacity = winInactive ? 0.36 : 1;
|
|
714
|
+
const controls = !opts.chrome
|
|
715
|
+
? ''
|
|
716
|
+
: isWindows
|
|
717
|
+
? winCaptionButtons(width, cy, winFg, winOpacity)
|
|
718
|
+
: macLights
|
|
719
|
+
.map((c, i) => `<circle cx="${lightInset + i * lightGap}" cy="${cy}" r="${lightR}" fill="${c}"/>`)
|
|
720
|
+
.join('');
|
|
721
|
+
// macOS centers the title; Windows left-aligns it where the app name sits.
|
|
722
|
+
const titleEl = !opts.chrome
|
|
723
|
+
? ''
|
|
724
|
+
: isWindows
|
|
725
|
+
? `<text x="${opts.padding}" y="${cy + 4}" text-anchor="start" ` +
|
|
726
|
+
`font-family="'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif" font-size="12" ` +
|
|
727
|
+
`fill="${winFg}" fill-opacity="${winOpacity}">${escapeXml(opts.title)}</text>`
|
|
728
|
+
: `<text x="${width / 2}" y="${cy + 4}" text-anchor="middle" ` +
|
|
729
|
+
`font-family="-apple-system, 'Segoe UI', sans-serif" font-size="13" ` +
|
|
730
|
+
`fill="${theme.foreground}" fill-opacity="0.65">${escapeXml(opts.title)}</text>`;
|
|
731
|
+
// Windows chrome uses the Figma title-bar fill (#202020); macOS keeps the
|
|
732
|
+
// Primer muted token.
|
|
733
|
+
const chromeFill = isWindows ? '#202020' : theme.chrome;
|
|
734
|
+
const header = opts.chrome
|
|
735
|
+
? `<path d="${roundedTopRect(width, barH, opts.radius)}" fill="${chromeFill}"/>` +
|
|
736
|
+
controls +
|
|
737
|
+
titleEl
|
|
738
|
+
: '';
|
|
739
|
+
const defs = opts.fontFace
|
|
740
|
+
? [`<defs><style type="text/css"><![CDATA[${opts.fontFace}]]></style></defs>`]
|
|
741
|
+
: [];
|
|
742
|
+
const frameRect = `x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${opts.radius}"`;
|
|
743
|
+
// Windows 11 frames the whole window in a subtle rgba(117,117,117,0.4) border
|
|
744
|
+
// (Figma); macOS keeps the Primer border token.
|
|
745
|
+
const frameBorder = isWindows ? '#757575' : theme.border;
|
|
746
|
+
const frameBorderOpacity = isWindows ? 0.4 : 1;
|
|
747
|
+
return [
|
|
748
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
|
749
|
+
`viewBox="0 0 ${width} ${height}" font-family="${escapeXml(opts.fontFamily)}" ` +
|
|
750
|
+
`font-size="${opts.fontSize}">`,
|
|
751
|
+
...defs,
|
|
752
|
+
// Background fill only. The 1px frame stroke is emitted LAST (below) so it
|
|
753
|
+
// wraps the whole window — chrome bar included — instead of being painted
|
|
754
|
+
// over by the chrome fill.
|
|
755
|
+
`<rect ${frameRect} fill="${theme.background}"/>`,
|
|
756
|
+
header,
|
|
757
|
+
`<g xml:space="preserve">`,
|
|
758
|
+
...bgRects,
|
|
759
|
+
...blockRects,
|
|
760
|
+
...textLines,
|
|
761
|
+
...cursorRects,
|
|
762
|
+
`</g>`,
|
|
763
|
+
`<rect ${frameRect} fill="none" stroke="${frameBorder}" stroke-opacity="${frameBorderOpacity}" stroke-width="1"/>`,
|
|
764
|
+
`</svg>`,
|
|
765
|
+
'',
|
|
766
|
+
].join('\n');
|
|
767
|
+
}
|