042aad9a
feat(web): theme and font settings, uploaded not baked
a73x 2026-08-21 14:14
Commit message
web/index.html
| Old | New | ||
|---|---|---|---|
| @@ -44,7 +44,11 @@ | |||
| 44 | that times devicePixelRatio, so glyphs rasterize at device resolution | 44 | that times devicePixelRatio, so glyphs rasterize at device resolution |
| 45 | instead of being a downscaled bitmap. No width:100% here: it would | 45 | instead of being a downscaled bitmap. No width:100% here: it would |
| 46 | stretch the backing store back out and undo exactly that. */ | 46 | stretch the backing store back out and undo exactly that. */ |
| 47 | .tile canvas { display: block; background: #000; } | 47 | /* The canvas background is TERMINAL ground, not chrome: it is what |
| 48 | shows before the first frame lands and along the sub-pixel edge the | ||
| 49 | backing-store rounding leaves. mux.js publishes the theme's | ||
| 50 | background here, so a light theme does not flash black. */ | ||
| 51 | .tile canvas { display: block; background: var(--term-bg, #000); } | ||
| 48 | /* The zoomed tile: same element, promoted. Keys go here. | 52 | /* The zoomed tile: same element, promoted. Keys go here. |
| 49 | INSET, not inset:0 — the ring of shade left visible around it is the | 53 | INSET, not inset:0 — the ring of shade left visible around it is the |
| 50 | only way back out (clicking it unzooms), and a tile covering the | 54 | only way back out (clicking it unzooms), and a tile covering the |
| @@ -70,6 +74,32 @@ | |||
| 70 | color: var(--fg); font: inherit; text-align: center; | 74 | color: var(--fg); font: inherit; text-align: center; |
| 71 | } | 75 | } |
| 72 | .tile.add .err { color: #e07a7a; font-size: 11px; } | 76 | .tile.add .err { color: #e07a7a; font-size: 11px; } |
| 77 | /* Settings live INSIDE the add tile rather than in a toolbar of their | ||
| 78 | own: the wall keeps its one standing control. It costs nothing to | ||
| 79 | make them unreachable while zoomed, too — the shade covers the wall — | ||
| 80 | so an open panel can never be holding the keyboard the terminal owns. */ | ||
| 81 | .tile.add .settings { width: 90%; font-size: 11px; } | ||
| 82 | .tile.add .settings summary { color: var(--dim); cursor: pointer; list-style: none; } | ||
| 83 | .tile.add .settings summary::-webkit-details-marker { display: none; } | ||
| 84 | .tile.add .settings summary:hover { color: var(--fg); } | ||
| 85 | .tile.add .settings .row { | ||
| 86 | display: flex; align-items: center; gap: 6px; margin-top: 6px; | ||
| 87 | } | ||
| 88 | .tile.add .settings .row > span { color: var(--dim); } | ||
| 89 | .tile.add .settings input[type="text"], | ||
| 90 | .tile.add .settings input[type="number"] { | ||
| 91 | flex: 1; min-width: 0; padding: 2px 4px; border: 1px solid #4a5261; | ||
| 92 | border-radius: 3px; background: transparent; color: var(--fg); | ||
| 93 | font: inherit; font-size: 11px; text-align: left; | ||
| 94 | } | ||
| 95 | .tile.add .settings input[type="number"] { flex: 0 0 4.5em; } | ||
| 96 | .tile.add .settings button { | ||
| 97 | padding: 2px 6px; border: 1px solid #4a5261; border-radius: 3px; | ||
| 98 | background: transparent; color: var(--fg); font: inherit; | ||
| 99 | font-size: 11px; cursor: pointer; | ||
| 100 | } | ||
| 101 | .tile.add .settings .note, .tile.add .settings .caveat { color: var(--dim); font-size: 11px; } | ||
| 102 | .tile.add .settings .note.bad { color: #e07a7a; } | ||
| 73 | .tile header .close, .tile header .spawn { | 103 | .tile header .close, .tile header .spawn { |
| 74 | padding: 0 6px; border: 0; background: transparent; color: var(--dim); | 104 | padding: 0 6px; border: 0; background: transparent; color: var(--dim); |
| 75 | font: inherit; cursor: pointer; | 105 | font: inherit; cursor: pointer; |
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -64,22 +64,48 @@ const TERMINAL = new Set(['stuck', 'exited', 'full']); | |||
| 64 | // the hub under a live page never flashes 'gone'. | 64 | // the hub under a live page never flashes 'gone'. |
| 65 | const GONE_AFTER_FAILURES = 4; | 65 | const GONE_AFTER_FAILURES = 4; |
| 66 | 66 | ||
| 67 | const FONT = '14px ui-monospace, monospace'; | 67 | // --- settings: the page's theme and font --- |
| 68 | const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000'; | 68 | // |
| 69 | 69 | // Both belong to the browser alone. Colour never reaches either VT engine: | |
| 70 | // The xterm 256 palette, computed once. | 70 | // a cell carries a palette INDEX and colorOf invents the hex at paint |
| 71 | const PALETTE = (() => { | 71 | // time, so a theme is repainted, never sent. Font is the exception that |
| 72 | const base = [ | 72 | // talks back — it decides how many cells fit the zoom box, which is a |
| 73 | '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', | 73 | // resize like any other, and only a zoomed tile is entitled to make it. |
| 74 | '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff', | 74 | const SETTINGS_KEY = 'mux.settings.v1'; |
| 75 | ]; | 75 | const DEFAULT_FONT_FAMILY = 'ui-monospace, monospace'; |
| 76 | const p = base.slice(); | 76 | const DEFAULT_FONT_SIZE = 14; |
| 77 | // A cell must stay measurable: at size 0 the zoom box divides by a | ||
| 78 | // zero-width cell, zoomCols is Infinity, and setUint16 hands the daemon a | ||
| 79 | // resize to no columns at all. | ||
| 80 | const FONT_SIZE_MIN = 6, FONT_SIZE_MAX = 72; | ||
| 81 | // A theme is a couple of dozen short lines. The cap is what stops a | ||
| 82 | // dropped PNG or a log file from being read into memory to find that out. | ||
| 83 | const THEME_BYTES_MAX = 1 << 16; | ||
| 84 | |||
| 85 | const ANSI_DEFAULT = [ | ||
| 86 | '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', | ||
| 87 | '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff', | ||
| 88 | ]; | ||
| 89 | const BUILTIN_FG = '#c8ccd4', BUILTIN_BG = '#000000', BUILTIN_SELECTION = '#6ab0e0'; | ||
| 90 | |||
| 91 | // The 240 colours above the ANSI 16 are an xterm formula, not a theme's to | ||
| 92 | // set: a theme that redefined one would leave the cube incoherent with the | ||
| 93 | // sixteen it is derived alongside. Themes name 0-15; we compute the rest. | ||
| 94 | function buildPalette(base16) { | ||
| 95 | const p = base16.slice(); | ||
| 77 | const lv = [0, 95, 135, 175, 215, 255]; | 96 | const lv = [0, 95, 135, 175, 215, 255]; |
| 78 | for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++) | 97 | for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++) |
| 79 | p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`); | 98 | p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`); |
| 80 | for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); } | 99 | for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); } |
| 81 | return p; | 100 | return p; |
| 82 | })(); | 101 | } |
| 102 | |||
| 103 | let FONT = `${DEFAULT_FONT_SIZE}px ${DEFAULT_FONT_FAMILY}`; | ||
| 104 | let DEFAULT_FG = BUILTIN_FG, DEFAULT_BG = BUILTIN_BG; | ||
| 105 | let PALETTE = buildPalette(ANSI_DEFAULT); | ||
| 106 | // The overlays stay translucent so the glyph underneath survives them: | ||
| 107 | // they are a theme colour plus an alpha byte, not colours of their own. | ||
| 108 | let CURSOR_FILL = `${BUILTIN_FG}88`, SELECTION_FILL = `${BUILTIN_SELECTION}55`; | ||
| 83 | 109 | ||
| 84 | function colorOf(packed, dflt) { | 110 | function colorOf(packed, dflt) { |
| 85 | const kind = packed >>> 24; | 111 | const kind = packed >>> 24; |
| @@ -104,15 +130,156 @@ function clipboardText(base64Bytes) { | |||
| 104 | } | 130 | } |
| 105 | } | 131 | } |
| 106 | 132 | ||
| 107 | // One cell's font metrics, measured once against the real font. | 133 | // ghostty accepts #rrggbb, #rgb, and bare rrggbb. Anything else — a CSS |
| 108 | const METRICS = (() => { | 134 | // colour name, a truncated hex, a line of a PNG — is not a colour here: |
| 135 | // refusing it is what lets "0 colours found" mean "not a theme file". | ||
| 136 | function normalizeHex(raw) { | ||
| 137 | const s = raw.trim().replace(/^#/, '').toLowerCase(); | ||
| 138 | if (/^[0-9a-f]{3}$/.test(s)) return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`; | ||
| 139 | if (/^[0-9a-f]{6}$/.test(s)) return `#${s}`; | ||
| 140 | return null; | ||
| 141 | } | ||
| 142 | |||
| 143 | const THEME_KEYS = { | ||
| 144 | background: 'bg', | ||
| 145 | foreground: 'fg', | ||
| 146 | 'cursor-color': 'cursor', | ||
| 147 | 'selection-background': 'selection', | ||
| 148 | }; | ||
| 149 | |||
| 150 | // A ghostty theme file is `key = value` lines with `#` comments. The one | ||
| 151 | // trap is that a palette line's VALUE contains an `=` of its own | ||
| 152 | // (`palette = 0=#45475a`), so every split here takes the FIRST one only — | ||
| 153 | // which is also what stops `keybind = ctrl+a=new_tab` in a whole config | ||
| 154 | // from being read as anything at all. | ||
| 155 | // | ||
| 156 | // Returns null when nothing was recognized: a file that is not a theme | ||
| 157 | // must be refused, never applied as an empty one. | ||
| 158 | function parseGhosttyTheme(text) { | ||
| 159 | const theme = { palette: new Array(16).fill(null), fg: null, bg: null, cursor: null, selection: null }; | ||
| 160 | let applied = 0, ignored = 0; | ||
| 161 | for (const line of text.replace(/^\uFEFF/, '').split(/\r?\n/)) { | ||
| 162 | const trimmed = line.trim(); | ||
| 163 | if (!trimmed || trimmed.startsWith('#')) continue; | ||
| 164 | const eq = trimmed.indexOf('='); | ||
| 165 | if (eq < 0) { ignored++; continue; } | ||
| 166 | const key = trimmed.slice(0, eq).trim(); | ||
| 167 | const value = trimmed.slice(eq + 1).trim(); | ||
| 168 | if (key === 'palette') { | ||
| 169 | const inner = value.indexOf('='); | ||
| 170 | const index = inner < 0 ? NaN : Number(value.slice(0, inner).trim()); | ||
| 171 | const hex = inner < 0 ? null : normalizeHex(value.slice(inner + 1)); | ||
| 172 | if (!Number.isInteger(index) || index < 0 || index > 15 || !hex) { ignored++; continue; } | ||
| 173 | theme.palette[index] = hex; // duplicates: last wins, as ghostty reads them | ||
| 174 | applied++; | ||
| 175 | continue; | ||
| 176 | } | ||
| 177 | const slot = THEME_KEYS[key]; | ||
| 178 | const hex = slot ? normalizeHex(value) : null; | ||
| 179 | // cursor-text and selection-foreground land here: a translucent | ||
| 180 | // overlay cannot honour a text colour, so naming one is not applying | ||
| 181 | // it and the count must say so. | ||
| 182 | if (!slot || !hex) { ignored++; continue; } | ||
| 183 | theme[slot] = hex; | ||
| 184 | applied++; | ||
| 185 | } | ||
| 186 | return applied === 0 ? null : { theme, applied, ignored }; | ||
| 187 | } | ||
| 188 | |||
| 189 | function clampFontSize(size) { | ||
| 190 | const n = Number(size); | ||
| 191 | if (!Number.isFinite(n) || n <= 0) return DEFAULT_FONT_SIZE; | ||
| 192 | return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(n))); | ||
| 193 | } | ||
| 194 | |||
| 195 | // One cell's font metrics, measured against the real font. Re-measured | ||
| 196 | // whenever the font changes, because every geometry below — the grid a | ||
| 197 | // zoomed tile claims, the canvas size, the cell under the pointer — | ||
| 198 | // divides by this. | ||
| 199 | function measureMetrics(font) { | ||
| 109 | const c = document.createElement('canvas').getContext('2d'); | 200 | const c = document.createElement('canvas').getContext('2d'); |
| 110 | c.font = FONT; | 201 | c.font = font; |
| 111 | const m = c.measureText('M'); | 202 | const m = c.measureText('M'); |
| 112 | const w = Math.ceil(m.width); | 203 | // Fallbacks for browsers without fontBoundingBox*: proportions of the |
| 113 | const h = Math.ceil((m.fontBoundingBoxAscent || 11) + (m.fontBoundingBoxDescent || 3)); | 204 | // em, since the 11/3 they replace only ever matched 14px. |
| 114 | return { w, h, ascent: Math.ceil(m.fontBoundingBoxAscent || 11) }; | 205 | const px = parseFloat(font) || DEFAULT_FONT_SIZE; |
| 115 | })(); | 206 | const ascent = Math.ceil(m.fontBoundingBoxAscent || px * 0.79); |
| 207 | const descent = Math.ceil(m.fontBoundingBoxDescent || px * 0.21); | ||
| 208 | // Floored at one pixel: a zero-width cell is how a resize to zero | ||
| 209 | // columns reaches the daemon. | ||
| 210 | return { w: Math.max(1, Math.ceil(m.width)), h: Math.max(1, ascent + descent), ascent }; | ||
| 211 | } | ||
| 212 | |||
| 213 | let METRICS = measureMetrics(FONT); | ||
| 214 | |||
| 215 | // Chrome with cookies blocked throws on ACCESSING localStorage, not just | ||
| 216 | // on write, and verify.js's shell has none at all — so every touch goes | ||
| 217 | // through here and a browser that says no costs us the setting, not boot. | ||
| 218 | function settingsStorage() { | ||
| 219 | try { return window.localStorage || null; } catch (_) { return null; } | ||
| 220 | } | ||
| 221 | |||
| 222 | function defaultSettings() { | ||
| 223 | return { theme: null, fontFamily: null, fontSize: DEFAULT_FONT_SIZE }; | ||
| 224 | } | ||
| 225 | |||
| 226 | function loadSettings() { | ||
| 227 | // No try around the read: settingsStorage is the one place that knows | ||
| 228 | // storage can refuse, and a second catch here would make its guard | ||
| 229 | // untestable — both would have to be removed before anything failed. | ||
| 230 | const raw = settingsStorage()?.getItem(SETTINGS_KEY) ?? null; | ||
| 231 | if (!raw) return defaultSettings(); | ||
| 232 | try { | ||
| 233 | const stored = JSON.parse(raw); | ||
| 234 | // Version-gated rather than merged: a shape from a build that spelled | ||
| 235 | // these differently is not worth guessing at. | ||
| 236 | if (!stored || stored.v !== 1) return defaultSettings(); | ||
| 237 | return { ...defaultSettings(), ...stored }; | ||
| 238 | } catch (_) { | ||
| 239 | return defaultSettings(); | ||
| 240 | } | ||
| 241 | } | ||
| 242 | |||
| 243 | function saveSettings(settings) { | ||
| 244 | try { | ||
| 245 | settingsStorage()?.setItem(SETTINGS_KEY, JSON.stringify({ ...settings, v: 1 })); | ||
| 246 | } catch (_) { | ||
| 247 | // Quota, private mode, or a browser that refuses: the setting is | ||
| 248 | // still live on this page, it just will not outlive the tab. | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | // The one place the render globals move. Every tile repaints from what | ||
| 253 | // its core already holds — a theme sends nothing — and the zoomed tile | ||
| 254 | // re-claims the grid, because the cell it fits its box with just changed. | ||
| 255 | function applySettings(settings) { | ||
| 256 | const theme = settings.theme; | ||
| 257 | FONT = `${clampFontSize(settings.fontSize)}px ${settings.fontFamily || DEFAULT_FONT_FAMILY}`; | ||
| 258 | METRICS = measureMetrics(FONT); | ||
| 259 | PALETTE = buildPalette(ANSI_DEFAULT.map((builtin, i) => theme?.palette[i] || builtin)); | ||
| 260 | DEFAULT_FG = theme?.fg || BUILTIN_FG; | ||
| 261 | DEFAULT_BG = theme?.bg || BUILTIN_BG; | ||
| 262 | CURSOR_FILL = `${theme?.cursor || DEFAULT_FG}88`; | ||
| 263 | SELECTION_FILL = `${theme?.selection || BUILTIN_SELECTION}55`; | ||
| 264 | // The canvas element's own background is terminal ground: it is what | ||
| 265 | // shows before the first frame lands and through the sub-pixel edge | ||
| 266 | // sizeCanvas's rounding leaves. Hardcoded black flashed through a light | ||
| 267 | // theme, so the CSS reads it from here. | ||
| 268 | document.documentElement?.style?.setProperty('--term-bg', DEFAULT_BG); | ||
| 269 | for (const tile of tilesById.values()) tile.repaintForSettings(); | ||
| 270 | zoomedTile?.sendResizeIfDiffers(); | ||
| 271 | } | ||
| 272 | |||
| 273 | // The page's live settings. Boot replaces this with what was persisted; | ||
| 274 | // the panel edits it through updateSettings so no caller can apply a | ||
| 275 | // change without saving it, or save one without applying it. | ||
| 276 | let settings = defaultSettings(); | ||
| 277 | |||
| 278 | function updateSettings(patch) { | ||
| 279 | settings = { ...settings, ...patch }; | ||
| 280 | applySettings(settings); | ||
| 281 | saveSettings(settings); | ||
| 282 | } | ||
| 116 | 283 | ||
| 117 | let compiledCore = null; // one compile, one instance per tile | 284 | let compiledCore = null; // one compile, one instance per tile |
| 118 | 285 | ||
| @@ -1112,6 +1279,18 @@ class Tile { | |||
| 1112 | // grid's worth of cells into the top-left corner of the bitmap. | 1279 | // grid's worth of cells into the top-left corner of the bitmap. |
| 1113 | this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0); | 1280 | this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0); |
| 1114 | } | 1281 | } |
| 1282 | // Repaint after the theme or the font moved under pixels that are | ||
| 1283 | // already on the canvas. Painting straight over them is not enough: a | ||
| 1284 | // wall tile draws through a scaled transform, so every cell fill lands | ||
| 1285 | // on fractional device pixels and its antialiased seam keeps a blend of | ||
| 1286 | // what was underneath. Repainting a light theme over a dark one that | ||
| 1287 | // way leaves the old colour in a lattice along every cell boundary. | ||
| 1288 | repaintForSettings() { | ||
| 1289 | if (!this.core) return; | ||
| 1290 | this.clearBackingCanvas(); | ||
| 1291 | this.reflow(); | ||
| 1292 | } | ||
| 1293 | |||
| 1115 | // The tile's CSS width moved (a window resize reflows the wall grid): | 1294 | // The tile's CSS width moved (a window resize reflows the wall grid): |
| 1116 | // re-fit and repaint from what the core already holds. No frame is | 1295 | // re-fit and repaint from what the core already holds. No frame is |
| 1117 | // requested and nothing is sent — a wall tile is passive. | 1296 | // requested and nothing is sent — a wall tile is passive. |
| @@ -1197,7 +1376,7 @@ class Tile { | |||
| 1197 | paintCursor() { | 1376 | paintCursor() { |
| 1198 | if (this.scrollPages !== 0) return; | 1377 | if (this.scrollPages !== 0) return; |
| 1199 | const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y(); | 1378 | const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y(); |
| 1200 | this.ctx.fillStyle = '#c8ccd488'; | 1379 | this.ctx.fillStyle = CURSOR_FILL; |
| 1201 | this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h); | 1380 | this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h); |
| 1202 | } | 1381 | } |
| 1203 | 1382 | ||
| @@ -1206,7 +1385,7 @@ class Tile { | |||
| 1206 | if (!ordered) return; | 1385 | if (!ordered) return; |
| 1207 | const [start, end] = ordered; | 1386 | const [start, end] = ordered; |
| 1208 | const cols = this.core.mux_cols(), rows = this.core.mux_rows(); | 1387 | const cols = this.core.mux_cols(), rows = this.core.mux_rows(); |
| 1209 | this.ctx.fillStyle = '#6ab0e055'; | 1388 | this.ctx.fillStyle = SELECTION_FILL; |
| 1210 | for (let y = 0; y < rows; y++) { | 1389 | for (let y = 0; y < rows; y++) { |
| 1211 | const screenRow = this.viewStartRow + y; | 1390 | const screenRow = this.viewStartRow + y; |
| 1212 | if (screenRow < start.row || screenRow > end.row) continue; | 1391 | if (screenRow < start.row || screenRow > end.row) continue; |
| @@ -1347,6 +1526,9 @@ function zoom(tile) { | |||
| 1347 | // core and no socket — connect() was never reached. | 1526 | // core and no socket — connect() was never reached. |
| 1348 | if (!tile.core || !tile.ws) return; | 1527 | if (!tile.core || !tile.ws) return; |
| 1349 | if (zoomedTile) unzoom(); | 1528 | if (zoomedTile) unzoom(); |
| 1529 | // Settings belong to the wall, and the wall is what zoom covers. Closing | ||
| 1530 | // the panel keeps it from sitting open and focusable behind the shade. | ||
| 1531 | addTileEl?.querySelector('.settings')?.removeAttribute('open'); | ||
| 1350 | zoomedTile = tile; | 1532 | zoomedTile = tile; |
| 1351 | tile.zoomed = true; | 1533 | tile.zoomed = true; |
| 1352 | tile.el.classList.add('zoomed'); | 1534 | tile.el.classList.add('zoomed'); |
| @@ -1384,9 +1566,12 @@ shade.addEventListener('click', unzoom); | |||
| 1384 | 1566 | ||
| 1385 | // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). | 1567 | // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). |
| 1386 | document.addEventListener('keydown', (ev) => { | 1568 | document.addEventListener('keydown', (ev) => { |
| 1387 | // The add input is a real text field: while it has focus its keys are the | 1569 | // The add tile's fields are real form controls: while one has focus its |
| 1388 | // user spelling a target, not input for any terminal. | 1570 | // keys are a target being spelled or a setting being edited, not input |
| 1389 | if (addInput && document.activeElement === addInput) return; | 1571 | // for any terminal. The whole tile, not just the add input — zoom hides |
| 1572 | // the settings panel behind the shade, but Tab still reaches it, and a | ||
| 1573 | // key answered by both would type the user's font size into their shell. | ||
| 1574 | if (addTileEl?.contains(document.activeElement)) return; | ||
| 1390 | const t = zoomedTile; | 1575 | const t = zoomedTile; |
| 1391 | if (!t) return; | 1576 | if (!t) return; |
| 1392 | if (ev.isComposing) return; // IME owns it; compositionend delivers | 1577 | if (ev.isComposing) return; // IME owns it; compositionend delivers |
| @@ -1590,6 +1775,68 @@ async function removeTile(id) { | |||
| 1590 | }); | 1775 | }); |
| 1591 | } | 1776 | } |
| 1592 | 1777 | ||
| 1778 | // The settings panel, wired inside the add tile. Every change goes through | ||
| 1779 | // updateSettings — no caller can apply one without saving it — and every | ||
| 1780 | // answer lands in .note, including the count of lines a file did NOT give | ||
| 1781 | // us, which is how "I uploaded my whole ghostty config" reads as partial | ||
| 1782 | // success instead of silence. | ||
| 1783 | function wireSettingsPanel(el) { | ||
| 1784 | const file = el.querySelector('.theme-file'); | ||
| 1785 | const clear = el.querySelector('.theme-clear'); | ||
| 1786 | const family = el.querySelector('.font-family'); | ||
| 1787 | const size = el.querySelector('.font-size'); | ||
| 1788 | const note = el.querySelector('.note'); | ||
| 1789 | const say = (text, bad) => { | ||
| 1790 | note.textContent = text; | ||
| 1791 | note.className = bad ? 'note bad' : 'note'; | ||
| 1792 | }; | ||
| 1793 | |||
| 1794 | family.value = settings.fontFamily || ''; | ||
| 1795 | size.value = clampFontSize(settings.fontSize); | ||
| 1796 | |||
| 1797 | file.addEventListener('change', async () => { | ||
| 1798 | const chosen = file.files?.[0]; | ||
| 1799 | if (!chosen) return; | ||
| 1800 | // Capped before a byte is read: no theme needs 64 KiB, and this is | ||
| 1801 | // the whole defence against someone picking a video. | ||
| 1802 | if (chosen.size > THEME_BYTES_MAX) { | ||
| 1803 | say(`too big for a theme file (${chosen.size} bytes)`, true); | ||
| 1804 | return; | ||
| 1805 | } | ||
| 1806 | let text = ''; | ||
| 1807 | try { | ||
| 1808 | text = await chosen.text(); | ||
| 1809 | } catch (_) { | ||
| 1810 | say('could not read that file', true); | ||
| 1811 | return; | ||
| 1812 | } | ||
| 1813 | const parsed = parseGhosttyTheme(text); | ||
| 1814 | if (!parsed) { | ||
| 1815 | say('no colours in that file — is it a ghostty theme?', true); | ||
| 1816 | return; | ||
| 1817 | } | ||
| 1818 | updateSettings({ theme: parsed.theme }); | ||
| 1819 | const skipped = parsed.ignored ? `, ${parsed.ignored} lines ignored` : ''; | ||
| 1820 | say(`${parsed.applied} colours applied${skipped}`, false); | ||
| 1821 | }); | ||
| 1822 | |||
| 1823 | clear.addEventListener('click', () => { | ||
| 1824 | updateSettings({ theme: null }); | ||
| 1825 | file.value = ''; // else re-picking the same file fires no change event | ||
| 1826 | say('theme cleared', false); | ||
| 1827 | }); | ||
| 1828 | |||
| 1829 | const applyFont = () => { | ||
| 1830 | const wanted = clampFontSize(size.value); | ||
| 1831 | size.value = wanted; // the clamp is shown, not applied behind the value | ||
| 1832 | updateSettings({ fontFamily: family.value.trim() || null, fontSize: wanted }); | ||
| 1833 | }; | ||
| 1834 | family.addEventListener('change', applyFont); | ||
| 1835 | // 'change', not 'input': typing 18 passes through 1, and each keystroke | ||
| 1836 | // would claim a new grid for every client on the zoomed session. | ||
| 1837 | size.addEventListener('change', applyFont); | ||
| 1838 | } | ||
| 1839 | |||
| 1593 | // The add tile: a tile-shaped door at the end of the wall. It is NOT a Tile | 1840 | // The add tile: a tile-shaped door at the end of the wall. It is NOT a Tile |
| 1594 | // — no core, no socket, never zooms — so the zoom click path (bound per | 1841 | // — no core, no socket, never zooms — so the zoom click path (bound per |
| 1595 | // Tile) cannot reach it. | 1842 | // Tile) cannot reach it. |
| @@ -1599,11 +1846,28 @@ function buildAddTile() { | |||
| 1599 | el.innerHTML = | 1846 | el.innerHTML = |
| 1600 | `<input type="text" name="target" aria-label="add a tile"` + | 1847 | `<input type="text" name="target" aria-label="add a tile"` + |
| 1601 | ` autocomplete="off" autocapitalize="off" spellcheck="false"` + | 1848 | ` autocomplete="off" autocapitalize="off" spellcheck="false"` + |
| 1602 | ` placeholder="HOST[#SESSION] | quic://HOST:PORT | --sock PATH"><span class="err"></span>`; | 1849 | ` placeholder="HOST[#SESSION] | quic://HOST:PORT | --sock PATH"><span class="err"></span>` + |
| 1850 | `<details class="settings"><summary>settings</summary>` + | ||
| 1851 | `<div class="row"><span>theme</span>` + | ||
| 1852 | `<input type="file" class="theme-file" aria-label="ghostty theme file">` + | ||
| 1853 | `<button type="button" class="theme-clear">clear</button></div>` + | ||
| 1854 | `<div class="row"><span>font</span>` + | ||
| 1855 | `<input type="text" class="font-family" aria-label="font family"` + | ||
| 1856 | ` autocomplete="off" spellcheck="false" placeholder="${DEFAULT_FONT_FAMILY}">` + | ||
| 1857 | `<input type="number" class="font-size" aria-label="font size"` + | ||
| 1858 | ` min="${FONT_SIZE_MIN}" max="${FONT_SIZE_MAX}"></div>` + | ||
| 1859 | `<div class="caveat">theme is this browser's alone; font size claims a` + | ||
| 1860 | ` new grid, so every client on the zoomed session reflows</div>` + | ||
| 1861 | `<div class="note"></div></details>`; | ||
| 1603 | const input = el.querySelector('input'); | 1862 | const input = el.querySelector('input'); |
| 1604 | const err = el.querySelector('.err'); | 1863 | const err = el.querySelector('.err'); |
| 1864 | const settingsEl = el.querySelector('.settings'); | ||
| 1865 | wireSettingsPanel(settingsEl); | ||
| 1605 | // The whole tile is the affordance; a click anywhere in it lands in the | 1866 | // The whole tile is the affordance; a click anywhere in it lands in the |
| 1606 | // input and goes no further (nothing above it should read this click). | 1867 | // input and goes no further (nothing above it should read this click). |
| 1868 | // The panel is the exception: its own controls are the click target, so | ||
| 1869 | // it stops the click before the tile can treat it as "type a target". | ||
| 1870 | settingsEl.addEventListener('click', (ev) => ev.stopPropagation()); | ||
| 1607 | el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); }); | 1871 | el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); }); |
| 1608 | // The add tile never moves and is never dragged, but it is the wall's | 1872 | // The add tile never moves and is never dragged, but it is the wall's |
| 1609 | // last child — so dropping on it is the only way to say "put this at the | 1873 | // last child — so dropping on it is the only way to say "put this at the |
| @@ -1666,6 +1930,18 @@ function buildAddTile() { | |||
| 1666 | 1930 | ||
| 1667 | // --- boot --- | 1931 | // --- boot --- |
| 1668 | (async function boot() { | 1932 | (async function boot() { |
| 1933 | // Before any tile exists, so the first frame is painted in the user's | ||
| 1934 | // colours at the user's cell size rather than repainted into them. | ||
| 1935 | settings = loadSettings(); | ||
| 1936 | applySettings(settings); | ||
| 1937 | // A second tab of the same hub is the same wall: settings follow it | ||
| 1938 | // live rather than waiting for a reload. A cleared key (newValue null) | ||
| 1939 | // means "back to defaults". | ||
| 1940 | window.addEventListener('storage', (ev) => { | ||
| 1941 | if (ev.key !== SETTINGS_KEY) return; | ||
| 1942 | settings = loadSettings(); | ||
| 1943 | applySettings(settings); | ||
| 1944 | }); | ||
| 1669 | compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); | 1945 | compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); |
| 1670 | addTileEl = buildAddTile(); | 1946 | addTileEl = buildAddTile(); |
| 1671 | addInput = addTileEl.querySelector('input'); | 1947 | addInput = addTileEl.querySelector('input'); |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -307,7 +307,11 @@ function executableCss(source) { | |||
| 307 | comment.replace(/[^\n\r]/g, ' ')); | 307 | comment.replace(/[^\n\r]/g, ' ')); |
| 308 | } | 308 | } |
| 309 | 309 | ||
| 310 | function browserShell(source) { | 310 | // opts.storage models what a browser is allowed to do with localStorage: |
| 311 | // a Map is a working store (prefill it to test what survives a reload), | ||
| 312 | // 'throws' is Chrome with cookies blocked — which throws on ACCESS, not | ||
| 313 | // just on write — and 'none' is no localStorage on window at all. | ||
| 314 | function browserShell(source, opts = {}) { | ||
| 311 | let activeElement = null; | 315 | let activeElement = null; |
| 312 | class FakeClassList { | 316 | class FakeClassList { |
| 313 | constructor(owner) { this.owner = owner; } | 317 | constructor(owner) { this.owner = owner; } |
| @@ -368,6 +372,13 @@ function browserShell(source) { | |||
| 368 | } | 372 | } |
| 369 | get innerHTML() { return this._innerHTML ?? ''; } | 373 | get innerHTML() { return this._innerHTML ?? ''; } |
| 370 | appendChild(child) { this.children.push(child); return child; } | 374 | appendChild(child) { this.children.push(child); return child; } |
| 375 | // Real nodes contain themselves, and the page asks this of the add | ||
| 376 | // tile to decide whether a key belongs to a form field or a terminal. | ||
| 377 | contains(node) { | ||
| 378 | if (node === this) return true; | ||
| 379 | return this.children.some((child) => child.contains(node)); | ||
| 380 | } | ||
| 381 | removeAttribute() {} | ||
| 371 | querySelector(selector) { | 382 | querySelector(selector) { |
| 372 | const match = (el) => selector.startsWith('.') | 383 | const match = (el) => selector.startsWith('.') |
| 373 | ? el.className.split(/\s+/).includes(selector.slice(1)) | 384 | ? el.className.split(/\s+/).includes(selector.slice(1)) |
| @@ -403,10 +414,23 @@ function browserShell(source) { | |||
| 403 | this.dispatchEvent('lostpointercapture', { pointerId }); | 414 | this.dispatchEvent('lostpointercapture', { pointerId }); |
| 404 | } | 415 | } |
| 405 | getContext() { | 416 | getContext() { |
| 406 | return { | 417 | // measureText answers the font it was ASKED about: a fixed 8/11/3 |
| 407 | measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), | 418 | // made every font assertion vacuous, since the page's whole font |
| 419 | // feature is "the cell changed size". Scaled from the 14px default | ||
| 420 | // so the geometry every other test pins stays exactly 8/11/3. | ||
| 421 | const ctx = { | ||
| 422 | font: '', | ||
| 423 | measureText() { | ||
| 424 | const px = parseFloat(this.font) || 14; | ||
| 425 | return { | ||
| 426 | width: 8 * px / 14, | ||
| 427 | fontBoundingBoxAscent: 11 * px / 14, | ||
| 428 | fontBoundingBoxDescent: 3 * px / 14, | ||
| 429 | }; | ||
| 430 | }, | ||
| 408 | setTransform() {}, clearRect() {}, fillRect() {}, fillText() {}, | 431 | setTransform() {}, clearRect() {}, fillRect() {}, fillText() {}, |
| 409 | }; | 432 | }; |
| 433 | return ctx; | ||
| 410 | } | 434 | } |
| 411 | focus() { activeElement = this; } | 435 | focus() { activeElement = this; } |
| 412 | blur() { if (activeElement === this) activeElement = null; } | 436 | blur() { if (activeElement === this) activeElement = null; } |
| @@ -417,10 +441,15 @@ function browserShell(source) { | |||
| 417 | ime: new FakeElement('input'), | 441 | ime: new FakeElement('input'), |
| 418 | wall: new FakeElement('div'), | 442 | wall: new FakeElement('div'), |
| 419 | }; | 443 | }; |
| 444 | const documentElement = new FakeElement('html'); | ||
| 445 | // A CSS custom property is set through the style object, not assigned: | ||
| 446 | // the page publishes terminal ground this way and the fake has to see it. | ||
| 447 | documentElement.style.setProperty = (name, value) => { documentElement.style[name] = value; }; | ||
| 420 | const documentListeners = new Map(); | 448 | const documentListeners = new Map(); |
| 421 | const document = { | 449 | const document = { |
| 422 | createElement: (tag) => new FakeElement(tag), | 450 | createElement: (tag) => new FakeElement(tag), |
| 423 | getElementById: (id) => elements[id], | 451 | getElementById: (id) => elements[id], |
| 452 | documentElement, | ||
| 424 | get activeElement() { return activeElement; }, | 453 | get activeElement() { return activeElement; }, |
| 425 | addEventListener(type, fn) { documentListeners.set(type, fn); }, | 454 | addEventListener(type, fn) { documentListeners.set(type, fn); }, |
| 426 | dispatchEvent(type, event) { return documentListeners.get(type)?.(event); }, | 455 | dispatchEvent(type, event) { return documentListeners.get(type)?.(event); }, |
| @@ -428,6 +457,22 @@ function browserShell(source) { | |||
| 428 | const timers = []; | 457 | const timers = []; |
| 429 | const intervals = []; | 458 | const intervals = []; |
| 430 | const navigator = { clipboard: undefined }; | 459 | const navigator = { clipboard: undefined }; |
| 460 | const windowFake = { addEventListener() {}, devicePixelRatio: 1 }; | ||
| 461 | if (opts.storage !== 'none') { | ||
| 462 | const store = opts.storage instanceof Map ? opts.storage : new Map(); | ||
| 463 | const localStorage = { | ||
| 464 | getItem: (key) => (store.has(key) ? store.get(key) : null), | ||
| 465 | setItem: (key, value) => store.set(key, String(value)), | ||
| 466 | removeItem: (key) => store.delete(key), | ||
| 467 | }; | ||
| 468 | if (opts.storage === 'throws') { | ||
| 469 | Object.defineProperty(windowFake, 'localStorage', { | ||
| 470 | get() { throw new Error('SecurityError: storage is denied'); }, | ||
| 471 | }); | ||
| 472 | } else { | ||
| 473 | windowFake.localStorage = localStorage; | ||
| 474 | } | ||
| 475 | } | ||
| 431 | const context = vm.createContext({ | 476 | const context = vm.createContext({ |
| 432 | ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, | 477 | ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, |
| 433 | Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, | 478 | Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, |
| @@ -436,16 +481,44 @@ function browserShell(source) { | |||
| 436 | clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; }, | 481 | clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; }, |
| 437 | setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; }, | 482 | setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; }, |
| 438 | clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; }, | 483 | clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; }, |
| 439 | window: { addEventListener() {}, devicePixelRatio: 1 }, | 484 | window: windowFake, |
| 440 | WebSocket: class { static OPEN = 1; }, | 485 | WebSocket: class { static OPEN = 1; }, |
| 441 | atob: globalThis.atob, | 486 | atob: globalThis.atob, |
| 442 | }); | 487 | }); |
| 443 | const beforeBoot = source.split('// --- boot ---')[0]; | 488 | const beforeBoot = source.split('// --- boot ---')[0]; |
| 444 | new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` + | 489 | new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` + |
| 445 | 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' + | 490 | 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' + |
| 446 | 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }\n' + | 491 | 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }, ' + |
| 492 | 'setAddTile(el, input) { addTileEl = el; addInput = input; }, ' + | ||
| 493 | 'parseGhosttyTheme: typeof parseGhosttyTheme === "function" ? parseGhosttyTheme : undefined, ' + | ||
| 494 | 'applySettings: typeof applySettings === "function" ? applySettings : undefined, ' + | ||
| 495 | 'loadSettings: typeof loadSettings === "function" ? loadSettings : undefined, ' + | ||
| 496 | 'saveSettings: typeof saveSettings === "function" ? saveSettings : undefined, ' + | ||
| 497 | 'THEME_BYTES_MAX: typeof THEME_BYTES_MAX === "number" ? THEME_BYTES_MAX : undefined, ' + | ||
| 498 | // The render globals are re-bound by applySettings, so they are read | ||
| 499 | // through a call: a captured copy would pin the value at boot and | ||
| 500 | // pass however badly the settings failed to land. | ||
| 501 | 'settingsState: () => ({ FONT, METRICS, PALETTE, DEFAULT_FG, DEFAULT_BG, CURSOR_FILL, SELECTION_FILL })\n' + | ||
| 447 | '};').runInContext(context); | 502 | '};').runInContext(context); |
| 448 | return { ...context.__verify, context, document, elements, navigator, timers, intervals }; | 503 | return { |
| 504 | ...context.__verify, context, document, documentElement, elements, navigator, timers, intervals, | ||
| 505 | // A tile the settings tests can paint through: not registered in the | ||
| 506 | // page's own map, so applySettings' reflow loop never reaches it and | ||
| 507 | // each assertion paints exactly when it says it does. | ||
| 508 | makeSettingsTile() { | ||
| 509 | const tile = new context.__verify.Tile(9, 'settings fixture', document.createElement('div'), ''); | ||
| 510 | tile.core = { | ||
| 511 | mux_cols: () => 10, | ||
| 512 | mux_rows: () => 5, | ||
| 513 | mux_history_rows: () => 0, | ||
| 514 | mux_cursor_x: () => 1, | ||
| 515 | mux_cursor_y: () => 2, | ||
| 516 | mux_mark_all_dirty() {}, | ||
| 517 | }; | ||
| 518 | tile.scrollPages = 0; | ||
| 519 | return tile; | ||
| 520 | }, | ||
| 521 | }; | ||
| 449 | } | 522 | } |
| 450 | 523 | ||
| 451 | async function flushPromises() { | 524 | async function flushPromises() { |
| @@ -2510,6 +2583,267 @@ function deltaPayload({ seq, history, cx, cy }, rowEntries) { | |||
| 2510 | return Buffer.concat(parts); | 2583 | return Buffer.concat(parts); |
| 2511 | } | 2584 | } |
| 2512 | 2585 | ||
| 2586 | // Settings: the browser's own theme and font. Neither is a protocol | ||
| 2587 | // concern — colour is invented at paint time from a palette INDEX, and | ||
| 2588 | // the daemon has no notion of a font — with one exception this file | ||
| 2589 | // pins: font size moves the cell, so a ZOOMED tile's grid moves with it. | ||
| 2590 | async function verifySettingsShell(shell, html) { | ||
| 2591 | const h = browserShell(shell); | ||
| 2592 | const parse = h.parseGhosttyTheme; | ||
| 2593 | check('shell exposes a ghostty theme parser', typeof parse, 'function'); | ||
| 2594 | check('shell exposes settings application', typeof h.applySettings, 'function'); | ||
| 2595 | check('shell exposes persisted settings loading', typeof h.loadSettings, 'function'); | ||
| 2596 | if (typeof parse !== 'function' || typeof h.applySettings !== 'function') return; | ||
| 2597 | |||
| 2598 | // --- the parser, against what a real theme file contains --- | ||
| 2599 | const mocha = [ | ||
| 2600 | 'palette = 0=#45475a', | ||
| 2601 | 'palette = 15=#bac2de', | ||
| 2602 | 'background = #1e1e2e', | ||
| 2603 | 'foreground = #cdd6f4', | ||
| 2604 | 'cursor-color = #f5e0dc', | ||
| 2605 | 'cursor-text = #1e1e2e', | ||
| 2606 | 'selection-background = #585b70', | ||
| 2607 | 'selection-foreground = #cdd6f4', | ||
| 2608 | ].join('\n'); | ||
| 2609 | const m = parse(mocha); | ||
| 2610 | // The value of a palette line CONTAINS an '=' — splitting on all of | ||
| 2611 | // them is the bug this asserts against. | ||
| 2612 | check('palette line splits on the first = only', m?.theme.palette[0], '#45475a'); | ||
| 2613 | check('palette line reads the last ANSI slot', m?.theme.palette[15], '#bac2de'); | ||
| 2614 | check('theme reads background', m?.theme.bg, '#1e1e2e'); | ||
| 2615 | check('theme reads foreground', m?.theme.fg, '#cdd6f4'); | ||
| 2616 | check('theme reads cursor colour', m?.theme.cursor, '#f5e0dc'); | ||
| 2617 | check('theme reads selection background', m?.theme.selection, '#585b70'); | ||
| 2618 | // A translucent overlay cannot honour a text colour: naming them is not | ||
| 2619 | // the same as applying them, so they count as ignored, not applied. | ||
| 2620 | check('theme counts keys it cannot honour as ignored', m?.ignored, 2); | ||
| 2621 | |||
| 2622 | check('parser tolerates missing spaces', parse('palette=8=#585b70')?.theme.palette[8], '#585b70'); | ||
| 2623 | check('parser tolerates CRLF', parse('background = #112233\r\nforeground = #445566\r\n')?.theme.bg, '#112233'); | ||
| 2624 | check('parser strips a BOM', parse('background = #112233')?.theme.bg, '#112233'); | ||
| 2625 | check('parser ignores comments', parse('# background = #ffffff\nbackground = #112233')?.theme.bg, '#112233'); | ||
| 2626 | check('parser accepts bare hex', parse('background = 1e1e2e')?.theme.bg, '#1e1e2e'); | ||
| 2627 | check('parser expands short hex', parse('background = #abc')?.theme.bg, '#aabbcc'); | ||
| 2628 | check('parser takes the last of duplicate keys', parse('background = #111111\nbackground = #222222')?.theme.bg, '#222222'); | ||
| 2629 | check('parser is case-insensitive about hex', parse('background = #AABBCC')?.theme.bg, '#aabbcc'); | ||
| 2630 | |||
| 2631 | // 16-255 are an xterm formula, not a theme's to set: taking one would | ||
| 2632 | // leave the cube incoherent with the sixteen below it. | ||
| 2633 | const beyond = parse('palette = 16=#ffffff\npalette = 255=#ffffff\nbackground = #111111'); | ||
| 2634 | check('parser refuses palette indices above 15', beyond?.theme.palette.length, 16); | ||
| 2635 | check('parser counts an out-of-range palette line as ignored', beyond?.ignored, 2); | ||
| 2636 | check('parser refuses a negative palette index', parse('palette = -1=#ffffff'), null); | ||
| 2637 | check('parser refuses a non-numeric palette index', parse('palette = x=#ffffff'), null); | ||
| 2638 | check('parser refuses a malformed colour', parse('background = #12345'), null); | ||
| 2639 | check('parser refuses a colour name', parse('background = rebeccapurple'), null); | ||
| 2640 | |||
| 2641 | // A file that is not a theme must be REFUSED, not silently applied as | ||
| 2642 | // an empty one: a PNG and a whole ghostty config both land here. | ||
| 2643 | check('parser refuses a file with no colours', parse('PNG\r\n\n binary'), null); | ||
| 2644 | check('parser refuses an empty file', parse(''), null); | ||
| 2645 | const config = 'font-family = JetBrains Mono\nkeybind = ctrl+a=new_tab\nbackground = #101010\n'; | ||
| 2646 | const fromConfig = parse(config); | ||
| 2647 | check('a whole ghostty config still yields its colours', fromConfig?.theme.bg, '#101010'); | ||
| 2648 | check('a whole ghostty config reports what it skipped', fromConfig?.ignored, 2); | ||
| 2649 | // keybind's value contains '=' too, so a first-= split must not let it | ||
| 2650 | // through as a colour. | ||
| 2651 | check('a keybind line is never mistaken for a colour', fromConfig?.applied, 1); | ||
| 2652 | |||
| 2653 | // --- applying a theme --- | ||
| 2654 | const before = h.settingsState(); | ||
| 2655 | check('the untouched palette is the full xterm 256', before.PALETTE.length, 256); | ||
| 2656 | const cube = before.PALETTE.slice(16).join('|'); | ||
| 2657 | h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: null }); | ||
| 2658 | const themed = h.settingsState(); | ||
| 2659 | check('applying a theme replaces the ANSI slots', themed.PALETTE[0], '#45475a'); | ||
| 2660 | check('applying a theme leaves the 240-colour cube computed', themed.PALETTE.slice(16).join('|'), cube); | ||
| 2661 | check('applying a theme keeps the palette 256 long', themed.PALETTE.length, 256); | ||
| 2662 | // A theme names 0 and 15 here and nothing between: the unnamed slots | ||
| 2663 | // keep the built-in ANSI colour rather than becoming undefined. | ||
| 2664 | check('a partial theme keeps built-in colours for unnamed slots', themed.PALETTE[1], before.PALETTE[1]); | ||
| 2665 | check('applying a theme sets the default foreground', themed.DEFAULT_FG, '#cdd6f4'); | ||
| 2666 | check('applying a theme sets the default background', themed.DEFAULT_BG, '#1e1e2e'); | ||
| 2667 | // The overlays are translucent so the glyph under them survives: theme | ||
| 2668 | // colour plus an alpha byte, never a second hardcoded constant. | ||
| 2669 | check('the cursor overlay derives from the theme', themed.CURSOR_FILL, '#f5e0dc88'); | ||
| 2670 | check('the selection overlay derives from the theme', themed.SELECTION_FILL, '#585b7055'); | ||
| 2671 | // The canvas element's own background is terminal ground: it shows | ||
| 2672 | // before the first frame and through sizeCanvas's rounding edge, so a | ||
| 2673 | // light theme with a black canvas flashes black. | ||
| 2674 | check('applying a theme republishes terminal ground', h.documentElement.style['--term-bg'], '#1e1e2e'); | ||
| 2675 | |||
| 2676 | const noCursor = parse('background = #101010\nforeground = #e0e0e0'); | ||
| 2677 | h.applySettings({ theme: noCursor.theme, fontFamily: null, fontSize: null }); | ||
| 2678 | const fallback = h.settingsState(); | ||
| 2679 | check('a theme without a cursor colour falls back to its foreground', fallback.CURSOR_FILL, '#e0e0e088'); | ||
| 2680 | check('a theme without a selection colour keeps the built-in blend', fallback.SELECTION_FILL, '#6ab0e055'); | ||
| 2681 | |||
| 2682 | h.applySettings({ theme: null, fontFamily: null, fontSize: null }); | ||
| 2683 | const reset = h.settingsState(); | ||
| 2684 | check('clearing the theme restores the built-in foreground', reset.DEFAULT_FG, before.DEFAULT_FG); | ||
| 2685 | check('clearing the theme restores the built-in palette', reset.PALETTE.join('|'), before.PALETTE.join('|')); | ||
| 2686 | |||
| 2687 | // --- the overlays actually paint what the theme said --- | ||
| 2688 | const painted = h.makeSettingsTile(); | ||
| 2689 | h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: null }); | ||
| 2690 | const fills = []; | ||
| 2691 | painted.ctx = { | ||
| 2692 | fillStyle: '', | ||
| 2693 | fillRect(x, y, width, height) { fills.push([x, y, width, height, this.fillStyle]); }, | ||
| 2694 | }; | ||
| 2695 | painted.paintCursor(); | ||
| 2696 | check('the cursor paints the themed colour', fills.at(-1)?.[4], '#f5e0dc88'); | ||
| 2697 | painted.viewStartRow = 0; | ||
| 2698 | painted.selection = { anchor: { row: 0, col: 0 }, active: { row: 0, col: 2 }, requestId: 1, text: null }; | ||
| 2699 | painted.paintSelection(); | ||
| 2700 | check('the selection paints the themed colour', fills.at(-1)?.[4], '#585b7055'); | ||
| 2701 | |||
| 2702 | // A repaint after a settings change must CLEAR first. A wall tile paints | ||
| 2703 | // through a scaled transform, so each cell fill lands on fractional | ||
| 2704 | // device pixels and its antialiased seam keeps a blend of whatever was | ||
| 2705 | // underneath — a light theme painted straight over a dark one keeps the | ||
| 2706 | // old colour in a lattice along every cell boundary. | ||
| 2707 | const stale = h.makeSettingsTile(); | ||
| 2708 | const order = []; | ||
| 2709 | stale.clearBackingCanvas = () => order.push('clear'); | ||
| 2710 | stale.reflow = () => order.push('reflow'); | ||
| 2711 | stale.repaintForSettings(); | ||
| 2712 | check('a settings repaint clears the stale palette before painting', order.join('|'), 'clear|reflow'); | ||
| 2713 | const coreless = h.makeSettingsTile(); | ||
| 2714 | coreless.core = null; | ||
| 2715 | coreless.clearBackingCanvas = () => order.push('clear-coreless'); | ||
| 2716 | coreless.repaintForSettings(); | ||
| 2717 | check('a tile with no core is left alone by a settings repaint', order.join('|'), 'clear|reflow'); | ||
| 2718 | |||
| 2719 | // --- font: the one setting that reaches the daemon --- | ||
| 2720 | h.applySettings({ theme: null, fontFamily: null, fontSize: null }); | ||
| 2721 | const baseMetrics = h.settingsState().METRICS; | ||
| 2722 | check('the default cell is measured from the default font', `${baseMetrics.w}x${baseMetrics.h}`, '8x14'); | ||
| 2723 | const zoomed = h.makeSettingsTile(); | ||
| 2724 | zoomed.zoomed = true; | ||
| 2725 | const baseCols = zoomed.zoomCols(); | ||
| 2726 | h.applySettings({ theme: null, fontFamily: null, fontSize: 28 }); | ||
| 2727 | const bigger = h.settingsState().METRICS; | ||
| 2728 | check('a larger font measures a larger cell', `${bigger.w}x${bigger.h}`, '16x28'); | ||
| 2729 | check('a larger font fits fewer columns in the same box', zoomed.zoomCols() < baseCols, true); | ||
| 2730 | check('the font string keeps the size-then-family shape', h.settingsState().FONT, '28px ui-monospace, monospace'); | ||
| 2731 | check('a font family is applied verbatim', (h.applySettings({ theme: null, fontFamily: 'JetBrains Mono', fontSize: 14 }), h.settingsState().FONT), '14px JetBrains Mono'); | ||
| 2732 | |||
| 2733 | // A zero size would divide the zoom box by a zero-width cell: zoomCols | ||
| 2734 | // becomes Infinity, setUint16 writes 0, and the daemon is told to | ||
| 2735 | // resize the session to no columns at all. | ||
| 2736 | h.applySettings({ theme: null, fontFamily: null, fontSize: 0 }); | ||
| 2737 | const clamped = h.settingsState(); | ||
| 2738 | check('a zero font size is clamped to something measurable', clamped.METRICS.w >= 1, true); | ||
| 2739 | check('a zero font size never yields an infinite column count', Number.isFinite(zoomed.zoomCols()), true); | ||
| 2740 | h.applySettings({ theme: null, fontFamily: null, fontSize: 4000 }); | ||
| 2741 | check('an absurd font size is clamped too', h.settingsState().METRICS.w < 200, true); | ||
| 2742 | check('a non-numeric font size falls back to the default', (h.applySettings({ theme: null, fontFamily: null, fontSize: 'huge' }), h.settingsState().FONT), '14px ui-monospace, monospace'); | ||
| 2743 | |||
| 2744 | // The resize is the SAME claim the window-resize handler makes: only | ||
| 2745 | // the zoomed tile sends it, and only when the grid really moved. | ||
| 2746 | const sent = []; | ||
| 2747 | const claiming = h.makeSettingsTile(); | ||
| 2748 | claiming.sendFrame = (type, payload) => sent.push([type, payload]); | ||
| 2749 | claiming.zoomed = true; | ||
| 2750 | h.setZoomedTile(claiming); | ||
| 2751 | h.applySettings({ theme: null, fontFamily: null, fontSize: 28 }); | ||
| 2752 | check('a font change claims the grid for the zoomed tile', sent.length, 1); | ||
| 2753 | const claimedCols = sent.length ? new DataView(sent[0][1].buffer, sent[0][1].byteOffset).getUint16(0, true) : 0; | ||
| 2754 | check('the claimed grid is the one the new cell fits', claimedCols, claiming.zoomCols()); | ||
| 2755 | check('the claimed grid is never zero columns', claimedCols > 0, true); | ||
| 2756 | // Theme is paint-local: with the grid already agreeing, applying one | ||
| 2757 | // must put nothing on the wire at all. | ||
| 2758 | claiming.core.mux_cols = () => claiming.zoomCols(); | ||
| 2759 | claiming.core.mux_rows = () => claiming.zoomRows(); | ||
| 2760 | sent.length = 0; | ||
| 2761 | h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: 28 }); | ||
| 2762 | check('a theme change sends nothing to the daemon', sent.length, 0); | ||
| 2763 | h.setZoomedTile(null); | ||
| 2764 | |||
| 2765 | // An unzoomed tile claims nothing, so its font is paint-only — the | ||
| 2766 | // wall stays passive however the settings move. | ||
| 2767 | const passive = h.makeSettingsTile(); | ||
| 2768 | const passiveSent = []; | ||
| 2769 | passive.sendFrame = (type, payload) => passiveSent.push([type, payload]); | ||
| 2770 | h.applySettings({ theme: null, fontFamily: null, fontSize: 20 }); | ||
| 2771 | check('an unzoomed tile sends nothing when the font changes', passiveSent.length, 0); | ||
| 2772 | |||
| 2773 | // --- the panel must not hold the keyboard the terminal owns --- | ||
| 2774 | // Zoom hides the add tile behind the shade, so the panel is unreachable | ||
| 2775 | // by pointer — but Tab still reaches it, and a key answered by both the | ||
| 2776 | // focused field and the session types the user's font size into their | ||
| 2777 | // shell. Caught in a real browser, not here, which is why it is pinned. | ||
| 2778 | const keyed = h.makeSettingsTile(); | ||
| 2779 | const typed = []; | ||
| 2780 | keyed.sendKey = () => typed.push('key'); | ||
| 2781 | keyed.zoomed = true; | ||
| 2782 | h.setZoomedTile(keyed); | ||
| 2783 | const fakeAddTile = h.document.createElement('div'); | ||
| 2784 | const fakeAddInput = fakeAddTile.appendChild(h.document.createElement('input')); | ||
| 2785 | const fakePanelField = fakeAddTile.appendChild(h.document.createElement('input')); | ||
| 2786 | h.setAddTile(fakeAddTile, fakeAddInput); | ||
| 2787 | const key = (target) => { | ||
| 2788 | target.focus(); | ||
| 2789 | h.document.dispatchEvent('keydown', { | ||
| 2790 | key: '2', target, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, | ||
| 2791 | preventDefault() {}, isComposing: false, | ||
| 2792 | }); | ||
| 2793 | }; | ||
| 2794 | key(fakeAddInput); | ||
| 2795 | check('the add input still keeps its keys out of the session', typed.length, 0); | ||
| 2796 | key(fakePanelField); | ||
| 2797 | check('a focused settings field keeps its keys out of the session', typed.length, 0); | ||
| 2798 | // The guard is the add tile, not "any input": the terminal still has to | ||
| 2799 | // receive keys when focus is anywhere else. | ||
| 2800 | const elsewhere = h.document.createElement('input'); | ||
| 2801 | key(elsewhere); | ||
| 2802 | check('a key with focus outside the add tile still reaches the session', typed.length > 0, true); | ||
| 2803 | h.setZoomedTile(null); | ||
| 2804 | h.setAddTile(null, null); | ||
| 2805 | |||
| 2806 | // --- persistence, where the browser is allowed to say no --- | ||
| 2807 | check('settings load to defaults with no stored value', h.loadSettings().fontSize, 14); | ||
| 2808 | const stored = browserShell(shell, { storage: new Map([['mux.settings.v1', JSON.stringify({ v: 1, fontSize: 20 })]]) }); | ||
| 2809 | check('stored settings are read back', stored.loadSettings().fontSize, 20); | ||
| 2810 | const corrupt = browserShell(shell, { storage: new Map([['mux.settings.v1', '{not json']]) }); | ||
| 2811 | check('corrupt stored settings fall back to defaults', corrupt.loadSettings().fontSize, 14); | ||
| 2812 | const older = browserShell(shell, { storage: new Map([['mux.settings.v1', JSON.stringify({ fontSize: 20 })]]) }); | ||
| 2813 | check('settings without the current version are discarded', older.loadSettings().fontSize, 14); | ||
| 2814 | // Chrome with cookies blocked throws on ACCESSING localStorage, not | ||
| 2815 | // just on write, and the page must still boot. | ||
| 2816 | const denied = browserShell(shell, { storage: 'throws' }); | ||
| 2817 | // Caught rather than allowed to propagate: an unguarded storage read | ||
| 2818 | // throws out of here, and a stack trace kills the whole run instead of | ||
| 2819 | // naming the one assertion that broke. | ||
| 2820 | let deniedSize = null; | ||
| 2821 | try { deniedSize = denied.loadSettings().fontSize; } catch (_) { deniedSize = 'threw'; } | ||
| 2822 | check('a browser that refuses storage still loads settings', deniedSize, 14); | ||
| 2823 | let saveThrew = false; | ||
| 2824 | try { denied.saveSettings({ theme: null, fontFamily: null, fontSize: 20 }); } catch (_) { saveThrew = true; } | ||
| 2825 | check('a browser that refuses storage still saves without throwing', saveThrew, false); | ||
| 2826 | const absent = browserShell(shell, { storage: 'none' }); | ||
| 2827 | check('a browser with no storage at all still loads settings', absent.loadSettings().fontSize, 14); | ||
| 2828 | |||
| 2829 | // --- the file the user hands us --- | ||
| 2830 | check('the theme upload is capped before it is read', typeof h.THEME_BYTES_MAX, 'number'); | ||
| 2831 | check('the cap is smaller than any plausible paste of a log', h.THEME_BYTES_MAX <= 1 << 20, true); | ||
| 2832 | |||
| 2833 | // --- the wall keeps its one standing control --- | ||
| 2834 | const executableHtmlCss = executableCss(html); | ||
| 2835 | check( | ||
| 2836 | 'terminal ground is a themeable variable, not a hardcoded black', | ||
| 2837 | /\.tile\s+canvas\s*\{[^}]*background\s*:\s*var\(--term-bg/.test(executableHtmlCss), | ||
| 2838 | true, | ||
| 2839 | ); | ||
| 2840 | check( | ||
| 2841 | 'the settings panel lives inside the add tile', | ||
| 2842 | /\.tile\.add\s+\.settings/.test(executableHtmlCss), | ||
| 2843 | true, | ||
| 2844 | ); | ||
| 2845 | } | ||
| 2846 | |||
| 2513 | async function main() { | 2847 | async function main() { |
| 2514 | const bin = fs.readFileSync(wasmPath); | 2848 | const bin = fs.readFileSync(wasmPath); |
| 2515 | const { instance } = await WebAssembly.instantiate(bin, {}); | 2849 | const { instance } = await WebAssembly.instantiate(bin, {}); |
| @@ -2914,6 +3248,7 @@ async function main() { | |||
| 2914 | const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); | 3248 | const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); |
| 2915 | await verifyClipboardShell(shell, html); | 3249 | await verifyClipboardShell(shell, html); |
| 2916 | await verifySelectionShell(shell, html); | 3250 | await verifySelectionShell(shell, html); |
| 3251 | await verifySettingsShell(shell, html); | ||
| 2917 | 3252 | ||
| 2918 | // Mutation fixture for the source checks below. A correct-looking route | 3253 | // Mutation fixture for the source checks below. A correct-looking route |
| 2919 | // in either kind of comment must be invisible, while live literal | 3254 | // in either kind of comment must be invisible, while live literal |