@vibecook/ghosttea-react 0.11.1 → 0.13.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.
Files changed (90) hide show
  1. package/README.md +86 -4
  2. package/dist/TerminalSurface.d.ts +53 -0
  3. package/dist/TerminalSurface.d.ts.map +1 -1
  4. package/dist/TerminalSurface.js +106 -12
  5. package/dist/TerminalSurface.js.map +1 -1
  6. package/dist/appearance/AppearanceSettings.d.ts +5 -2
  7. package/dist/appearance/AppearanceSettings.d.ts.map +1 -1
  8. package/dist/appearance/AppearanceSettings.js +14 -3
  9. package/dist/appearance/AppearanceSettings.js.map +1 -1
  10. package/dist/appearance/BackdropBlurControl.d.ts +5 -0
  11. package/dist/appearance/BackdropBlurControl.d.ts.map +1 -0
  12. package/dist/appearance/BackdropBlurControl.js +42 -0
  13. package/dist/appearance/BackdropBlurControl.js.map +1 -0
  14. package/dist/appearance/types.d.ts +10 -0
  15. package/dist/appearance/types.d.ts.map +1 -1
  16. package/dist/catalog-budget.d.ts +2 -2
  17. package/dist/catalog-budget.d.ts.map +1 -1
  18. package/dist/catalog-budget.js +2 -2
  19. package/dist/catalog-budget.js.map +1 -1
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +2 -0
  22. package/dist/config.js.map +1 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/link-input.d.ts +18 -0
  28. package/dist/link-input.d.ts.map +1 -0
  29. package/dist/link-input.js +27 -0
  30. package/dist/link-input.js.map +1 -0
  31. package/dist/renderers/background-geometry.d.ts +16 -0
  32. package/dist/renderers/background-geometry.d.ts.map +1 -0
  33. package/dist/renderers/background-geometry.js +20 -0
  34. package/dist/renderers/background-geometry.js.map +1 -0
  35. package/dist/renderers/glyph-atlas.d.ts +43 -0
  36. package/dist/renderers/glyph-atlas.d.ts.map +1 -0
  37. package/dist/renderers/glyph-atlas.js +175 -0
  38. package/dist/renderers/glyph-atlas.js.map +1 -0
  39. package/dist/renderers/light-adaptation.d.ts +36 -0
  40. package/dist/renderers/light-adaptation.d.ts.map +1 -0
  41. package/dist/renderers/light-adaptation.js +332 -0
  42. package/dist/renderers/light-adaptation.js.map +1 -0
  43. package/dist/renderers/types.d.ts +23 -0
  44. package/dist/renderers/types.d.ts.map +1 -1
  45. package/dist/renderers/types.js.map +1 -1
  46. package/dist/renderers/webgpu-renderer.d.ts +4 -0
  47. package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
  48. package/dist/renderers/webgpu-renderer.js +331 -239
  49. package/dist/renderers/webgpu-renderer.js.map +1 -1
  50. package/dist/routed-control.d.ts +26 -2
  51. package/dist/routed-control.d.ts.map +1 -1
  52. package/dist/routed-control.js +145 -27
  53. package/dist/routed-control.js.map +1 -1
  54. package/dist/routed-frames.d.ts +1 -1
  55. package/dist/routed-frames.d.ts.map +1 -1
  56. package/dist/routed-frames.js +3 -7
  57. package/dist/routed-frames.js.map +1 -1
  58. package/dist/row-damage.d.ts +21 -0
  59. package/dist/row-damage.d.ts.map +1 -0
  60. package/dist/row-damage.js +76 -0
  61. package/dist/row-damage.js.map +1 -0
  62. package/dist/runtime.d.ts +97 -3
  63. package/dist/runtime.d.ts.map +1 -1
  64. package/dist/runtime.js +489 -49
  65. package/dist/runtime.js.map +1 -1
  66. package/dist/styles.css +24 -0
  67. package/dist/terminal-render.worker.js +979 -297
  68. package/dist/terminal-render.worker.js.map +4 -4
  69. package/dist/use-terminal-links.d.ts +17 -0
  70. package/dist/use-terminal-links.d.ts.map +1 -0
  71. package/dist/use-terminal-links.js +101 -0
  72. package/dist/use-terminal-links.js.map +1 -0
  73. package/dist/worker-messages.d.ts +27 -1
  74. package/dist/worker-messages.d.ts.map +1 -1
  75. package/dist/workspace/Workspace.d.ts +19 -3
  76. package/dist/workspace/Workspace.d.ts.map +1 -1
  77. package/dist/workspace/Workspace.js +53 -20
  78. package/dist/workspace/Workspace.js.map +1 -1
  79. package/dist/workspace/index.d.ts +2 -2
  80. package/dist/workspace/index.d.ts.map +1 -1
  81. package/dist/workspace/index.js.map +1 -1
  82. package/dist/workspace/pane-focus.d.ts +8 -0
  83. package/dist/workspace/pane-focus.d.ts.map +1 -0
  84. package/dist/workspace/pane-focus.js +24 -0
  85. package/dist/workspace/pane-focus.js.map +1 -0
  86. package/dist/workspace/pane-layout.d.ts +5 -2
  87. package/dist/workspace/pane-layout.d.ts.map +1 -1
  88. package/dist/workspace/pane-layout.js +45 -6
  89. package/dist/workspace/pane-layout.js.map +1 -1
  90. package/package.json +4 -4
@@ -3,9 +3,11 @@ import { CursorStyle, GlyphFormat } from "@vibecook/ghosttea-frame";
3
3
  import { emptyRenderMetrics } from "../performance.js";
4
4
  import { CELL_WIDTH, LINE_HEIGHT, ORIGIN_X, ORIGIN_Y, effectiveCursorStyle, renderedSizeChanged, } from "./types.js";
5
5
  import { graphemeCellWidth, splitGraphemes } from "../cell-width.js";
6
+ import { backgroundRunBounds } from "./background-geometry.js";
6
7
  import { rowsForDamage } from "./render-damage.js";
7
8
  import { SHADER_EFFECT_WGSL } from "./shader-effects.js";
8
- const ATLAS_SIZE = 2048;
9
+ import { createLightAdaptation, darkPaintedBase, ensureContrast } from "./light-adaptation.js";
10
+ import { AtlasBatchFullError, AtlasCapacityError, GlyphAtlasPages } from "./glyph-atlas.js";
9
11
  const GEOMETRY_CACHE_LIMIT = 8;
10
12
  const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
11
13
  const EFFECT_MODES = {
@@ -179,74 +181,29 @@ class DynamicVertexBuffer {
179
181
  }
180
182
  class FallbackGlyphAtlas {
181
183
  device;
182
- #texture;
183
- #sampler;
184
- #bindGroup;
185
- #cache = new Map();
184
+ pages;
186
185
  #scratch = new OffscreenCanvas(1, 1);
187
- #x = 1;
188
- #y = 1;
189
- #rowHeight = 0;
190
186
  #uploadBytes = 0;
191
187
  #uploadCalls = 0;
192
- #generation = 0;
193
188
  constructor(device, bindGroupLayout) {
194
189
  this.device = device;
195
- this.#texture = device.createTexture({
196
- label: "terminal glyph atlas",
197
- size: [ATLAS_SIZE, ATLAS_SIZE],
198
- format: "rgba8unorm",
199
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
200
- });
201
- this.#sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" });
202
- this.#bindGroup = device.createBindGroup({
203
- label: "terminal glyph atlas bindings",
204
- layout: bindGroupLayout,
205
- entries: [
206
- { binding: 0, resource: this.#texture.createView() },
207
- { binding: 1, resource: this.#sampler },
208
- ],
209
- });
190
+ this.pages = new GlyphAtlasPages(device, bindGroupLayout, "terminal diagnostic glyph atlas", "rgba8unorm", 1);
210
191
  }
211
192
  get bindGroup() {
212
- return this.#bindGroup;
193
+ return this.pages.bindGroup;
213
194
  }
214
195
  get generation() {
215
- return this.#generation;
196
+ return this.pages.generation;
216
197
  }
217
198
  prepare(values, dpr) {
218
199
  const scale = Math.max(1, Math.min(3, dpr));
219
- const all = [...new Set(values)].filter((value) => value.trim().length > 0);
220
- const missing = all.filter((value) => !this.#cache.has(`${scale.toFixed(2)}:${value}`));
221
- if (this.#fits(missing, scale, this.#x, this.#y, this.#rowHeight))
222
- return;
223
- this.#cache.clear();
224
- this.#generation += 1;
225
- this.#x = 1;
226
- this.#y = 1;
227
- this.#rowHeight = 0;
228
- if (!this.#fits(all, scale, this.#x, this.#y, this.#rowHeight)) {
229
- throw new Error("The diagnostic glyph atlas cannot fit the text visible in one frame");
230
- }
231
- }
232
- #fits(values, scale, startX, startY, startRowHeight) {
233
- let x = startX;
234
- let y = startY;
235
- let rowHeight = startRowHeight;
236
- for (const value of values) {
237
- const width = Math.max(1, Math.ceil(CELL_WIDTH * graphemeCellWidth(value) * scale));
238
- const height = Math.max(1, Math.ceil(LINE_HEIGHT * scale));
239
- if (x + width + 1 > ATLAS_SIZE) {
240
- x = 1;
241
- y += rowHeight + 1;
242
- rowHeight = 0;
243
- }
244
- if (y + height + 1 > ATLAS_SIZE)
245
- return false;
246
- x += width + 1;
247
- rowHeight = Math.max(rowHeight, height);
248
- }
249
- return true;
200
+ this.pages.prepare([...new Set(values)]
201
+ .filter((value) => value.trim().length > 0)
202
+ .map((value) => ({
203
+ key: `${scale.toFixed(2)}:${value}`,
204
+ width: Math.max(1, Math.ceil(CELL_WIDTH * graphemeCellWidth(value) * scale)),
205
+ height: Math.max(1, Math.ceil(LINE_HEIGHT * scale)),
206
+ })));
250
207
  }
251
208
  glyph(grapheme, dpr) {
252
209
  if (grapheme.trim().length === 0)
@@ -254,19 +211,11 @@ class FallbackGlyphAtlas {
254
211
  const scale = Math.max(1, Math.min(3, dpr));
255
212
  const cells = graphemeCellWidth(grapheme);
256
213
  const key = `${scale.toFixed(2)}:${grapheme}`;
257
- const cached = this.#cache.get(key);
214
+ const cached = this.pages.get(key);
258
215
  if (cached)
259
216
  return cached;
260
217
  const width = Math.max(1, Math.ceil(CELL_WIDTH * cells * scale));
261
218
  const height = Math.max(1, Math.ceil(LINE_HEIGHT * scale));
262
- if (this.#x + width + 1 > ATLAS_SIZE) {
263
- this.#x = 1;
264
- this.#y += this.#rowHeight + 1;
265
- this.#rowHeight = 0;
266
- }
267
- if (this.#y + height + 1 > ATLAS_SIZE) {
268
- throw new Error("Temporary glyph atlas exhausted");
269
- }
270
219
  this.#scratch.width = width;
271
220
  this.#scratch.height = height;
272
221
  const context = this.#scratch.getContext("2d", { willReadFrequently: true });
@@ -278,23 +227,17 @@ class FallbackGlyphAtlas {
278
227
  context.textBaseline = "top";
279
228
  context.fillText(grapheme, 0, Math.max(0, Math.floor((height - 15 * scale) / 2)));
280
229
  const image = context.getImageData(0, 0, width, height);
281
- this.device.queue.writeTexture({ texture: this.#texture, origin: [this.#x, this.#y] }, image.data, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]);
230
+ const { location, texture, origin } = this.pages.insert(key, width, height, cells);
231
+ this.device.queue.writeTexture({ texture, origin }, image.data, { bytesPerRow: width * 4, rowsPerImage: height }, [
232
+ width,
233
+ height,
234
+ ]);
282
235
  this.#uploadBytes += image.data.byteLength;
283
236
  this.#uploadCalls += 1;
284
- const location = {
285
- u0: this.#x / ATLAS_SIZE,
286
- v0: this.#y / ATLAS_SIZE,
287
- u1: (this.#x + width) / ATLAS_SIZE,
288
- v1: (this.#y + height) / ATLAS_SIZE,
289
- cells,
290
- };
291
- this.#cache.set(key, location);
292
- this.#x += width + 1;
293
- this.#rowHeight = Math.max(this.#rowHeight, height);
294
237
  return location;
295
238
  }
296
239
  destroy() {
297
- this.#texture.destroy();
240
+ this.pages.destroy();
298
241
  }
299
242
  uploadMetrics() {
300
243
  return { bytes: this.#uploadBytes, calls: this.#uploadCalls };
@@ -304,111 +247,44 @@ class NativeGlyphAtlas {
304
247
  device;
305
248
  label;
306
249
  format;
307
- #texture;
308
- #sampler;
309
- #bindGroup;
310
- #cache = new Map();
311
- #x = 1;
312
- #y = 1;
313
- #rowHeight = 0;
250
+ pages;
314
251
  #uploadBytes = 0;
315
252
  #uploadCalls = 0;
316
- #generation = 0;
317
253
  constructor(device, bindGroupLayout, label, format) {
318
254
  this.device = device;
319
255
  this.label = label;
320
256
  this.format = format;
321
- this.#texture = device.createTexture({
322
- label,
323
- size: [ATLAS_SIZE, ATLAS_SIZE],
324
- format,
325
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
326
- });
327
- this.#sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" });
328
- this.#bindGroup = device.createBindGroup({
329
- label: `${label} bindings`,
330
- layout: bindGroupLayout,
331
- entries: [
332
- { binding: 0, resource: this.#texture.createView() },
333
- { binding: 1, resource: this.#sampler },
334
- ],
335
- });
257
+ // At most 16 MiB mono + 32 MiB color + 16 MiB diagnostic pages. Empty
258
+ // formats allocate no textures. The ordinary ASCII working set uses 4 MiB.
259
+ this.pages = new GlyphAtlasPages(device, bindGroupLayout, label, format, format === "r8unorm" ? 4 : 2);
336
260
  }
337
261
  get bindGroup() {
338
- return this.#bindGroup;
262
+ return this.pages.bindGroup;
339
263
  }
340
264
  get generation() {
341
- return this.#generation;
265
+ return this.pages.generation;
342
266
  }
343
267
  prepare(definitions) {
344
- const all = new Map();
345
- const missing = new Map();
346
- for (const definition of definitions) {
347
- all.set(definition.id, definition);
348
- if (!this.#cache.has(definition.id))
349
- missing.set(definition.id, definition);
350
- }
351
- if (this.#fits(missing.values(), this.#x, this.#y, this.#rowHeight))
352
- return;
353
- this.#cache.clear();
354
- this.#generation += 1;
355
- this.#x = 1;
356
- this.#y = 1;
357
- this.#rowHeight = 0;
358
- if (!this.#fits(all.values(), this.#x, this.#y, this.#rowHeight)) {
359
- throw new Error(`${this.label} cannot fit the glyphs visible in one frame`);
360
- }
361
- }
362
- #fits(definitions, startX, startY, startRowHeight) {
363
- let x = startX;
364
- let y = startY;
365
- let rowHeight = startRowHeight;
366
- for (const { width, height } of definitions) {
367
- if (width + 2 > ATLAS_SIZE || height + 2 > ATLAS_SIZE)
368
- return false;
369
- if (x + width + 1 > ATLAS_SIZE) {
370
- x = 1;
371
- y += rowHeight + 1;
372
- rowHeight = 0;
373
- }
374
- if (y + height + 1 > ATLAS_SIZE)
375
- return false;
376
- x += width + 1;
377
- rowHeight = Math.max(rowHeight, height);
378
- }
379
- return true;
268
+ this.pages.prepare(Array.from(definitions, (definition) => ({
269
+ key: definition.id,
270
+ width: definition.width,
271
+ height: definition.height,
272
+ })));
380
273
  }
381
274
  glyph(definition) {
382
- const cached = this.#cache.get(definition.id);
275
+ const cached = this.pages.get(definition.id);
383
276
  if (cached)
384
277
  return cached;
385
278
  const { width, height } = definition;
386
- if (this.#x + width + 1 > ATLAS_SIZE) {
387
- this.#x = 1;
388
- this.#y += this.#rowHeight + 1;
389
- this.#rowHeight = 0;
390
- }
391
- if (this.#y + height + 1 > ATLAS_SIZE)
392
- throw new Error(`${this.label} exhausted`);
393
- const pixels = definition.pixels;
279
+ const { location, texture, origin } = this.pages.insert(definition.id, width, height, 0);
394
280
  const bytesPerPixel = this.format === "r8unorm" ? 1 : 4;
395
- this.device.queue.writeTexture({ texture: this.#texture, origin: [this.#x, this.#y] }, pixels, { bytesPerRow: width * bytesPerPixel, rowsPerImage: height }, [width, height]);
396
- this.#uploadBytes += pixels.byteLength;
281
+ this.device.queue.writeTexture({ texture, origin }, definition.pixels, { bytesPerRow: width * bytesPerPixel, rowsPerImage: height }, [width, height]);
282
+ this.#uploadBytes += definition.pixels.byteLength;
397
283
  this.#uploadCalls += 1;
398
- const location = {
399
- u0: this.#x / ATLAS_SIZE,
400
- v0: this.#y / ATLAS_SIZE,
401
- u1: (this.#x + width) / ATLAS_SIZE,
402
- v1: (this.#y + height) / ATLAS_SIZE,
403
- cells: 0,
404
- };
405
- this.#cache.set(definition.id, location);
406
- this.#x += width + 1;
407
- this.#rowHeight = Math.max(this.#rowHeight, height);
408
284
  return location;
409
285
  }
410
286
  destroy() {
411
- this.#texture.destroy();
287
+ this.pages.destroy();
412
288
  }
413
289
  uploadMetrics() {
414
290
  return { bytes: this.#uploadBytes, calls: this.#uploadCalls };
@@ -437,13 +313,14 @@ function createCachedVertexBuffer(device, label, data) {
437
313
  device.queue.writeBuffer(buffer, 0, data);
438
314
  return buffer;
439
315
  }
440
- function geometryCacheKey(view, rows, hasNativeRows, atlasGenerations) {
316
+ function geometryCacheKey(view, rows, hasNativeRows, atlasGenerations, adaptationKey) {
441
317
  const selection = view.selection
442
318
  ? `${view.selection.anchor.row},${view.selection.anchor.column},${view.selection.focus.row},${view.selection.focus.column}`
443
319
  : "-";
444
320
  return [
445
321
  view.sessionEpoch.toString(),
446
322
  view.layoutEpoch.toString(),
323
+ view.cols,
447
324
  hasNativeRows ? "native" : "fallback",
448
325
  rows.map((row) => `${row}:${view.rowRevisions[row]?.toString() ?? "-"}`).join(","),
449
326
  [
@@ -455,6 +332,8 @@ function geometryCacheKey(view, rows, hasNativeRows, atlasGenerations) {
455
332
  ...view.theme.selectionForeground,
456
333
  ].join(","),
457
334
  view.theme.backgroundOpacityCells ? "opacity-cells" : "opaque-cells",
335
+ view.theme.minimumContrast ?? 1,
336
+ adaptationKey,
458
337
  selection,
459
338
  effectiveCursorStyle(view) === CursorStyle.Block ? `${view.cursor.x},${view.cursor.y}` : "-",
460
339
  atlasGenerations.join(","),
@@ -657,22 +536,51 @@ function pushBlockCursorBackground(vertices, view, renderRows, scale, viewportWi
657
536
  return;
658
537
  pushRectangle(vertices, (ORIGIN_X + view.cursor.x * CELL_WIDTH) * scale, (ORIGIN_Y + view.cursor.y * LINE_HEIGHT) * scale, CELL_WIDTH * scale, LINE_HEIGHT * scale, view.theme.cursor, viewportWidth, viewportHeight);
659
538
  }
660
- function resolveStyle(style, theme) {
539
+ function resolveStyle(style, theme, adaptation = null) {
661
540
  let foreground = style?.foreground ? rgb(style.foreground) : theme.foreground;
662
541
  let background = style?.background ? rgb(style.background) : null;
663
- if (style?.inverse) {
542
+ let fill = foreground;
543
+ if (adaptation) {
544
+ // Application colors were designed on the app's dark base; theme-owned
545
+ // defaults already belong to the light theme and pass through.
546
+ const source = { foreground: style?.foreground, background: style?.background };
547
+ if (style?.inverse) {
548
+ background = source.foreground ? adaptation.surface(source.foreground) : theme.foreground;
549
+ foreground = source.background
550
+ ? adaptation.ink(source.background, source.foreground ?? adaptation.base, style?.backgroundPalette)
551
+ : theme.background;
552
+ fill = foreground;
553
+ }
554
+ else {
555
+ foreground = source.foreground
556
+ ? adaptation.ink(source.foreground, source.background ?? adaptation.base, style?.foregroundPalette)
557
+ : theme.foreground;
558
+ background = source.background ? adaptation.surface(source.background) : null;
559
+ fill = source.foreground ? adaptation.surface(source.foreground) : theme.foreground;
560
+ }
561
+ }
562
+ else if (style?.inverse) {
664
563
  const originalForeground = foreground;
665
564
  foreground = background ?? theme.background;
666
565
  background = originalForeground;
566
+ fill = foreground;
667
567
  }
668
568
  if (background && theme.backgroundOpacityCells) {
669
569
  background = [background[0], background[1], background[2], theme.background[3]];
670
570
  }
671
- if (style?.faint)
571
+ // Graphics (box drawing, block elements) and color glyphs keep the raw
572
+ // foreground, as in Ghostty; the backdrop ignores window transparency.
573
+ let text = ensureContrast(foreground, over(background ?? theme.background, theme.background), theme.minimumContrast ?? 1);
574
+ if (style?.faint) {
672
575
  foreground = [foreground[0], foreground[1], foreground[2], foreground[3] * 0.55];
576
+ fill = [fill[0], fill[1], fill[2], fill[3] * 0.55];
577
+ text = [text[0], text[1], text[2], text[3] * 0.55];
578
+ }
673
579
  return {
674
580
  foreground,
675
581
  background,
582
+ fill,
583
+ text,
676
584
  underline: style?.underline ?? false,
677
585
  strikethrough: style?.strikethrough ?? false,
678
586
  invisible: style?.invisible ?? false,
@@ -819,7 +727,10 @@ export class WebGpuTerminalRenderer {
819
727
  #monoAtlas;
820
728
  #colorAtlas;
821
729
  #fallbackAtlas;
730
+ #adaptations = new Map();
822
731
  #performanceMeasurementEnabled = false;
732
+ #submission = { queueSubmits: 0, surfaceIds: [] };
733
+ #scrollScratch;
823
734
  constructor(device, format) {
824
735
  this.device = device;
825
736
  this.format = format;
@@ -1018,6 +929,10 @@ export class WebGpuTerminalRenderer {
1018
929
  geometryCache: new Map(),
1019
930
  geometryCandidates: new Map(),
1020
931
  sceneValid: false,
932
+ adaptation: null,
933
+ sceneHadSelection: false,
934
+ sceneHadOverhang: false,
935
+ sceneCursorRow: null,
1021
936
  };
1022
937
  this.#surfaces.set(id, surface);
1023
938
  this.#configure(surface);
@@ -1039,6 +954,10 @@ export class WebGpuTerminalRenderer {
1039
954
  surface.cursorBuffer.destroy();
1040
955
  clearGeometryCache(surface);
1041
956
  this.#surfaces.delete(id);
957
+ if (this.#surfaces.size === 0) {
958
+ this.#scrollScratch?.destroy();
959
+ this.#scrollScratch = undefined;
960
+ }
1042
961
  }
1043
962
  resize(id, size) {
1044
963
  const surface = this.#surfaces.get(id);
@@ -1071,7 +990,10 @@ export class WebGpuTerminalRenderer {
1071
990
  label: "terminal scene before Ghostty custom shaders",
1072
991
  size: [Math.max(1, width), Math.max(1, height)],
1073
992
  format: this.format,
1074
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
993
+ usage: GPUTextureUsage.RENDER_ATTACHMENT |
994
+ GPUTextureUsage.TEXTURE_BINDING |
995
+ GPUTextureUsage.COPY_SRC |
996
+ GPUTextureUsage.COPY_DST,
1075
997
  });
1076
998
  }
1077
999
  #createEffectTexture(width, height, suffix) {
@@ -1188,37 +1110,227 @@ export class WebGpuTerminalRenderer {
1188
1110
  drawCalls: effectPassCount,
1189
1111
  };
1190
1112
  }
1191
- render(id, view) {
1192
- const surface = this.#surfaces.get(id);
1193
- if (!surface)
1113
+ /**
1114
+ * Engage, re-key, or release light adaptation for a pane. Any change remaps
1115
+ * every color, so the persistent scene and cached geometry are invalidated.
1116
+ */
1117
+ #prepareAdaptation(surface, view) {
1118
+ const base = darkPaintedBase(view, surface.adaptation !== null);
1119
+ let next = null;
1120
+ if (base) {
1121
+ const key = `${base.join(",")}|${view.theme.background.join(",")}|${view.theme.foreground.join(",")}`;
1122
+ next = this.#adaptations.get(key) ?? null;
1123
+ if (!next) {
1124
+ next = createLightAdaptation(base, view.theme.background, view.theme.foreground);
1125
+ this.#adaptations.set(key, next);
1126
+ if (this.#adaptations.size > 8)
1127
+ this.#adaptations.delete(this.#adaptations.keys().next().value);
1128
+ }
1129
+ }
1130
+ if ((next?.key ?? null) === (surface.adaptation?.key ?? null))
1194
1131
  return;
1195
- const encoder = this.device.createCommandEncoder({ label: `terminal frame ${id}` });
1196
- const metrics = view.damage?.geometryChanged === false
1197
- ? this.#encodeCachedRender(id, view, encoder)
1198
- : this.#encodeRender(id, view, encoder);
1199
- this.device.queue.submit([encoder.finish()]);
1200
- if (metrics)
1201
- metrics.queueSubmits = 1;
1202
- return metrics;
1132
+ surface.adaptation = next;
1133
+ surface.sceneValid = false;
1134
+ clearGeometryCache(surface);
1135
+ }
1136
+ submission() {
1137
+ return this.#submission;
1138
+ }
1139
+ #releaseAtlasBatch() {
1140
+ this.#monoAtlas.pages.releaseBatch();
1141
+ this.#colorAtlas.pages.releaseBatch();
1142
+ this.#fallbackAtlas.pages.releaseBatch();
1143
+ }
1144
+ #prepareAtlases(view, renderRows, dpr) {
1145
+ const mono = new Map();
1146
+ const color = new Map();
1147
+ for (const row of renderRows) {
1148
+ for (const glyph of view.nativeRows[row] ?? []) {
1149
+ const definition = view.glyphDefinitions.get(glyph.glyphId);
1150
+ if (definition)
1151
+ (definition.format === GlyphFormat.Alpha8 ? mono : color).set(definition.id, definition);
1152
+ }
1153
+ }
1154
+ this.#monoAtlas.prepare(mono.values());
1155
+ this.#colorAtlas.prepare(color.values());
1156
+ const fallback = !view.nativeRows.some((row) => row.length > 0);
1157
+ if (fallback) {
1158
+ this.#fallbackAtlas.prepare(renderRows.flatMap((row) => splitGraphemes(view.rows[row] ?? "")), dpr);
1159
+ }
1160
+ // Pin only after every format fits, so a rejected pane cannot occupy the
1161
+ // batch budget with pages that no pending draw actually references.
1162
+ if (mono.size > 0)
1163
+ this.#monoAtlas.pages.pin();
1164
+ if (color.size > 0)
1165
+ this.#colorAtlas.pages.pin();
1166
+ if (fallback && renderRows.length > 0)
1167
+ this.#fallbackAtlas.pages.pin();
1168
+ }
1169
+ render(id, view) {
1170
+ return this.renderBatch([{ id, view }])[0];
1171
+ }
1172
+ #canCopyScroll(surface, view) {
1173
+ const shift = view.damage?.scrollRows ?? 0;
1174
+ const top = ORIGIN_Y * surface.dpr;
1175
+ const line = LINE_HEIGHT * surface.dpr;
1176
+ const count = view.rows.length;
1177
+ return (surface.sceneValid &&
1178
+ !view.damage?.full &&
1179
+ Number.isSafeInteger(shift) &&
1180
+ shift !== 0 &&
1181
+ Math.abs(shift) < count &&
1182
+ !view.selection &&
1183
+ !surface.sceneHadSelection &&
1184
+ Number.isInteger(top) &&
1185
+ Number.isInteger(line) &&
1186
+ top + count * line <= surface.canvas.height);
1187
+ }
1188
+ #prepareScrollScratch(entries) {
1189
+ let width = 0, height = 0;
1190
+ for (const { id, view } of entries) {
1191
+ const surface = this.#surfaces.get(id);
1192
+ if (!surface || !this.#canCopyScroll(surface, view))
1193
+ continue;
1194
+ width = Math.max(width, surface.canvas.width);
1195
+ height = Math.max(height, view.rows.length * LINE_HEIGHT * surface.dpr);
1196
+ }
1197
+ if (width === 0)
1198
+ return false;
1199
+ // One shared staging texture, never one per pane. Fall back to redraw if
1200
+ // the batch's maximum extent would exceed this fixed memory ceiling.
1201
+ if (width * height * 4 > 64 * 1024 * 1024)
1202
+ return false;
1203
+ if (!this.#scrollScratch || this.#scrollScratch.width < width || this.#scrollScratch.height < height) {
1204
+ this.#scrollScratch?.destroy();
1205
+ this.#scrollScratch = this.device.createTexture({
1206
+ label: "terminal shared scroll staging",
1207
+ size: [width, height],
1208
+ format: this.format,
1209
+ usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST,
1210
+ });
1211
+ }
1212
+ return true;
1213
+ }
1214
+ #copyScroll(surface, view, encoder) {
1215
+ const dy = view.damage.scrollRows * LINE_HEIGHT * surface.dpr;
1216
+ const top = ORIGIN_Y * surface.dpr;
1217
+ const size = [surface.canvas.width, view.rows.length * LINE_HEIGHT * surface.dpr - Math.abs(dy)];
1218
+ // A texture cannot copy overlapping regions to itself. Encoder ordering
1219
+ // lets all panes safely share this scratch texture, even within one batch.
1220
+ encoder.copyTextureToTexture({ texture: surface.sceneTexture, origin: [0, top + Math.max(0, dy)] }, { texture: this.#scrollScratch }, size);
1221
+ encoder.copyTextureToTexture({ texture: this.#scrollScratch }, { texture: surface.sceneTexture, origin: [0, top + Math.max(0, -dy)] }, size);
1203
1222
  }
1204
1223
  renderBatch(entries) {
1205
- const active = entries.filter(({ id }) => this.#surfaces.has(id));
1206
- if (active.length === 0)
1207
- return entries.map(() => undefined);
1208
- const encoder = this.device.createCommandEncoder({ label: `terminal frame batch (${active.length} panes)` });
1224
+ this.#submission = { queueSubmits: 0, surfaceIds: [] };
1225
+ this.#releaseAtlasBatch();
1226
+ const prepared = entries.map(({ id, view }) => {
1227
+ const surface = this.#surfaces.get(id);
1228
+ // Light adaptation first, for the whole batch: a change remaps every color
1229
+ // and invalidates the scene, so every decision below (the overhang
1230
+ // shortcut, the scroll staging, the scroll copy) sees it invalidated.
1231
+ if (surface)
1232
+ this.#prepareAdaptation(surface, view);
1233
+ const overhang = view.damage?.geometryChanged === false && surface?.sceneValid
1234
+ ? surface.sceneHadOverhang
1235
+ : view.nativeRows.some((row) => row.some((glyph) => glyph.y < 0 || glyph.y + glyph.height > LINE_HEIGHT));
1236
+ // Row clears cannot reconstruct cross-row alpha composition at the
1237
+ // repair boundary. Preserve those glyphs with a complete scene redraw.
1238
+ if (view.damage &&
1239
+ !view.damage.full &&
1240
+ (view.damage.scrollRows || view.damage.rows.size > 0) &&
1241
+ (overhang || surface?.sceneHadOverhang)) {
1242
+ view = { ...view, damage: { full: true, rows: new Set(), geometryChanged: true } };
1243
+ }
1244
+ return { id, view, overhang };
1245
+ });
1246
+ const canStageScroll = this.#prepareScrollScratch(prepared);
1247
+ let encoder;
1248
+ let pendingIds = [];
1209
1249
  const byId = new Map();
1210
- for (const { id, view } of active) {
1211
- byId.set(id, view.damage?.geometryChanged === false
1212
- ? this.#encodeCachedRender(id, view, encoder)
1213
- : this.#encodeRender(id, view, encoder));
1250
+ const submit = () => {
1251
+ if (encoder && pendingIds.length > 0) {
1252
+ this.device.queue.submit([encoder.finish()]);
1253
+ this.#submission.queueSubmits += 1;
1254
+ this.#submission.surfaceIds.push(...pendingIds);
1255
+ const first = pendingIds.map((id) => byId.get(id)).find((metric) => metric !== undefined);
1256
+ if (first)
1257
+ first.queueSubmits += 1;
1258
+ }
1259
+ encoder = undefined;
1260
+ pendingIds = [];
1261
+ this.#releaseAtlasBatch();
1262
+ };
1263
+ try {
1264
+ for (const { id, view: originalView, overhang } of prepared) {
1265
+ const surface = this.#surfaces.get(id);
1266
+ if (!surface)
1267
+ continue;
1268
+ let view = originalView;
1269
+ const copyScroll = canStageScroll && this.#canCopyScroll(surface, view);
1270
+ if (view.damage?.scrollRows) {
1271
+ if (copyScroll) {
1272
+ const rows = new Set(view.damage.rows);
1273
+ const shift = view.damage.scrollRows;
1274
+ // Repaint exposed rows, the two clipped edges and the cursor
1275
+ // baked into the last submitted scene (possibly several frames ago).
1276
+ rows.add(0);
1277
+ rows.add(view.rows.length - 1);
1278
+ for (let row = 0; row < view.rows.length; row++) {
1279
+ if (row + shift < 0 || row + shift >= view.rows.length)
1280
+ rows.add(row);
1281
+ }
1282
+ if (surface.sceneCursorRow !== null)
1283
+ rows.add(surface.sceneCursorRow - shift);
1284
+ if (effectiveCursorStyle(view) !== null)
1285
+ rows.add(view.cursor.y);
1286
+ view = { ...view, damage: { ...view.damage, rows, geometryChanged: true } };
1287
+ }
1288
+ else {
1289
+ view = { ...view, damage: { full: true, rows: new Set(), geometryChanged: true } };
1290
+ }
1291
+ }
1292
+ const count = Math.max(view.rows.length, view.nativeRows.length, view.nativeStyleRows.length);
1293
+ const { rows } = rowsForDamage(count, view.damage, surface.sceneValid);
1294
+ try {
1295
+ try {
1296
+ this.#prepareAtlases(view, rows, surface.dpr);
1297
+ }
1298
+ catch (error) {
1299
+ if (!(error instanceof AtlasBatchFullError) || pendingIds.length === 0)
1300
+ throw error;
1301
+ // All recyclable pages are referenced by pending draws. Submit
1302
+ // them before overwriting any texels, then retry this pane once.
1303
+ submit();
1304
+ this.#prepareAtlases(view, rows, surface.dpr);
1305
+ }
1306
+ }
1307
+ catch (error) {
1308
+ if (!(error instanceof AtlasCapacityError))
1309
+ throw error;
1310
+ // Capacity failure is isolated to this pane and preserves its old
1311
+ // scene. Other panes still submit; new content/resize can retry it.
1312
+ console.error(`[terminal-renderer] cannot render ${id}`, error);
1313
+ continue;
1314
+ }
1315
+ encoder ??= this.device.createCommandEncoder({ label: "terminal frame batch" });
1316
+ if (copyScroll)
1317
+ this.#copyScroll(surface, view, encoder);
1318
+ byId.set(id, view.damage?.geometryChanged === false
1319
+ ? this.#encodeCachedRender(id, view, encoder)
1320
+ : this.#encodeRender(id, view, encoder));
1321
+ surface.sceneHadSelection = view.selection !== null;
1322
+ surface.sceneHadOverhang = overhang;
1323
+ surface.sceneCursorRow = effectiveCursorStyle(view) === null ? null : view.cursor.y;
1324
+ pendingIds.push(id);
1325
+ }
1326
+ submit();
1327
+ }
1328
+ finally {
1329
+ this.#releaseAtlasBatch();
1214
1330
  }
1215
- this.device.queue.submit([encoder.finish()]);
1216
- const firstMetrics = [...byId.values()].find((metrics) => metrics !== undefined);
1217
- if (firstMetrics)
1218
- firstMetrics.queueSubmits = 1;
1219
1331
  return entries.map(({ id }) => byId.get(id));
1220
1332
  }
1221
- #buildGeometry(view, renderRows, rowCount, fullRedraw, hasNativeRows, scale, viewportWidth, viewportHeight) {
1333
+ #buildGeometry(view, renderRows, rowCount, fullRedraw, hasNativeRows, scale, viewportWidth, viewportHeight, adaptation) {
1222
1334
  const rectangleVertices = [];
1223
1335
  const glyphVertices = [];
1224
1336
  const colorGlyphVertices = [];
@@ -1228,22 +1340,10 @@ export class WebGpuTerminalRenderer {
1228
1340
  const cached = resolvedStyles.get(styleId);
1229
1341
  if (cached)
1230
1342
  return cached;
1231
- const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme);
1343
+ const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, adaptation);
1232
1344
  resolvedStyles.set(styleId, resolved);
1233
1345
  return resolved;
1234
1346
  };
1235
- const monoDefinitions = new Map();
1236
- const colorDefinitions = new Map();
1237
- for (const row of renderRows) {
1238
- for (const glyph of view.nativeRows[row] ?? []) {
1239
- const definition = view.glyphDefinitions.get(glyph.glyphId);
1240
- if (!definition)
1241
- continue;
1242
- (definition.format === GlyphFormat.Rgba8Premultiplied ? colorDefinitions : monoDefinitions).set(definition.id, definition);
1243
- }
1244
- }
1245
- this.#monoAtlas.prepare(monoDefinitions.values());
1246
- this.#colorAtlas.prepare(colorDefinitions.values());
1247
1347
  if (!fullRedraw) {
1248
1348
  for (const row of renderRows) {
1249
1349
  const top = row === 0 ? 0 : (ORIGIN_Y + row * LINE_HEIGHT) * scale;
@@ -1259,7 +1359,10 @@ export class WebGpuTerminalRenderer {
1259
1359
  const style = styleFor(run.styleId);
1260
1360
  if (!style.background)
1261
1361
  continue;
1262
- pushRectangle(rectangleVertices, (ORIGIN_X + run.cellStart * CELL_WIDTH) * scale, (ORIGIN_Y + row * LINE_HEIGHT) * scale, run.cellSpan * CELL_WIDTH * scale, LINE_HEIGHT * scale, style.background, viewportWidth, viewportHeight);
1362
+ const bounds = backgroundRunBounds(run, row, view.cols, rowCount, scale, viewportWidth, viewportHeight);
1363
+ if (!bounds)
1364
+ continue;
1365
+ pushRectangle(rectangleVertices, bounds.x, bounds.y, bounds.width, bounds.height, style.background, viewportWidth, viewportHeight);
1263
1366
  }
1264
1367
  }
1265
1368
  const backgroundInstanceCount = rectangleVertices.length / 10;
@@ -1276,9 +1379,6 @@ export class WebGpuTerminalRenderer {
1276
1379
  }
1277
1380
  pushBlockCursorBackground(rectangleVertices, view, renderRows, scale, viewportWidth, viewportHeight);
1278
1381
  const selectionInstanceCount = rectangleVertices.length / 10 - backgroundInstanceCount;
1279
- if (!hasNativeRows) {
1280
- this.#fallbackAtlas.prepare(renderRows.flatMap((row) => splitGraphemes(view.rows[row] ?? "")), scale);
1281
- }
1282
1382
  if (hasNativeRows) {
1283
1383
  for (const row of renderRows) {
1284
1384
  const { box: boxCells, block: blockCells } = specialDrawingCells(view.rows[row] ?? "");
@@ -1310,12 +1410,14 @@ export class WebGpuTerminalRenderer {
1310
1410
  let backdrop = style.background ?? view.theme.background;
1311
1411
  if (isSelected)
1312
1412
  backdrop = view.theme.selection;
1313
- if (pushBlockElement(rectangleVertices, blockElement, instance.cellStart, row, scale, over(foreground, backdrop), viewportWidth, viewportHeight))
1413
+ const blockForeground = foreground === style.foreground ? style.fill : foreground;
1414
+ if (pushBlockElement(rectangleVertices, blockElement, instance.cellStart, row, scale, over(blockForeground, backdrop), viewportWidth, viewportHeight))
1314
1415
  continue;
1315
1416
  }
1316
- const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas;
1317
- const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices;
1318
- pushGlyph(vertices, (ORIGIN_X + instance.x) * scale, (ORIGIN_Y + row * LINE_HEIGHT + instance.y) * scale, instance.width * scale, instance.height * scale, atlas.glyph(definition), foreground, viewportWidth, viewportHeight);
1417
+ const mono = definition.format === GlyphFormat.Alpha8;
1418
+ const atlas = mono ? this.#monoAtlas : this.#colorAtlas;
1419
+ const vertices = mono ? glyphVertices : colorGlyphVertices;
1420
+ pushGlyph(vertices, (ORIGIN_X + instance.x) * scale, (ORIGIN_Y + row * LINE_HEIGHT + instance.y) * scale, instance.width * scale, instance.height * scale, atlas.glyph(definition), mono && foreground === style.foreground ? style.text : foreground, viewportWidth, viewportHeight);
1319
1421
  }
1320
1422
  }
1321
1423
  }
@@ -1347,10 +1449,10 @@ export class WebGpuTerminalRenderer {
1347
1449
  const rowTop = (ORIGIN_Y + row * LINE_HEIGHT) * scale;
1348
1450
  const stroke = Math.max(1, Math.round(scale));
1349
1451
  if (style.underline) {
1350
- pushRectangle(rectangleVertices, x, Math.round(rowTop + 16 * scale), width, stroke, style.foreground, viewportWidth, viewportHeight);
1452
+ pushRectangle(rectangleVertices, x, Math.round(rowTop + 16 * scale), width, stroke, style.text, viewportWidth, viewportHeight);
1351
1453
  }
1352
1454
  if (style.strikethrough) {
1353
- pushRectangle(rectangleVertices, x, Math.round(rowTop + 9 * scale), width, stroke, style.foreground, viewportWidth, viewportHeight);
1455
+ pushRectangle(rectangleVertices, x, Math.round(rowTop + 9 * scale), width, stroke, style.text, viewportWidth, viewportHeight);
1354
1456
  }
1355
1457
  }
1356
1458
  }
@@ -1394,22 +1496,10 @@ export class WebGpuTerminalRenderer {
1394
1496
  const cached = resolvedStyles.get(styleId);
1395
1497
  if (cached)
1396
1498
  return cached;
1397
- const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme);
1499
+ const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, surface.adaptation);
1398
1500
  resolvedStyles.set(styleId, resolved);
1399
1501
  return resolved;
1400
1502
  };
1401
- const monoDefinitions = new Map();
1402
- const colorDefinitions = new Map();
1403
- for (const row of renderRows) {
1404
- for (const glyph of view.nativeRows[row] ?? []) {
1405
- const definition = view.glyphDefinitions.get(glyph.glyphId);
1406
- if (!definition)
1407
- continue;
1408
- (definition.format === GlyphFormat.Rgba8Premultiplied ? colorDefinitions : monoDefinitions).set(definition.id, definition);
1409
- }
1410
- }
1411
- this.#monoAtlas.prepare(monoDefinitions.values());
1412
- this.#colorAtlas.prepare(colorDefinitions.values());
1413
1503
  if (!fullRedraw) {
1414
1504
  for (const row of renderRows) {
1415
1505
  const top = row === 0 ? 0 : (ORIGIN_Y + row * LINE_HEIGHT) * scale;
@@ -1425,7 +1515,10 @@ export class WebGpuTerminalRenderer {
1425
1515
  const style = styleFor(run.styleId);
1426
1516
  if (!style.background)
1427
1517
  continue;
1428
- pushRectangle(rectangleVertices, (ORIGIN_X + run.cellStart * CELL_WIDTH) * scale, (ORIGIN_Y + row * LINE_HEIGHT) * scale, run.cellSpan * CELL_WIDTH * scale, LINE_HEIGHT * scale, style.background, viewportWidth, viewportHeight);
1518
+ const bounds = backgroundRunBounds(run, row, view.cols, rowCount, scale, viewportWidth, viewportHeight);
1519
+ if (!bounds)
1520
+ continue;
1521
+ pushRectangle(rectangleVertices, bounds.x, bounds.y, bounds.width, bounds.height, style.background, viewportWidth, viewportHeight);
1429
1522
  }
1430
1523
  }
1431
1524
  const backgroundInstanceCount = rectangleVertices.length / 10;
@@ -1443,9 +1536,6 @@ export class WebGpuTerminalRenderer {
1443
1536
  pushBlockCursorBackground(rectangleVertices, view, renderRows, scale, viewportWidth, viewportHeight);
1444
1537
  const selectionInstanceCount = rectangleVertices.length / 10 - backgroundInstanceCount;
1445
1538
  const hasNativeRows = view.nativeRows.some((row) => row.length > 0);
1446
- if (!hasNativeRows) {
1447
- this.#fallbackAtlas.prepare(renderRows.flatMap((row) => splitGraphemes(view.rows[row] ?? "")), scale);
1448
- }
1449
1539
  if (hasNativeRows) {
1450
1540
  for (const row of renderRows) {
1451
1541
  const { box: boxCells, block: blockCells } = specialDrawingCells(view.rows[row] ?? "");
@@ -1479,13 +1569,15 @@ export class WebGpuTerminalRenderer {
1479
1569
  let backdrop = style.background ?? view.theme.background;
1480
1570
  if (isSelected)
1481
1571
  backdrop = view.theme.selection;
1482
- const blockColor = over(foreground, backdrop);
1572
+ const blockForeground = foreground === style.foreground ? style.fill : foreground;
1573
+ const blockColor = over(blockForeground, backdrop);
1483
1574
  if (pushBlockElement(rectangleVertices, blockElement, instance.cellStart, row, scale, blockColor, viewportWidth, viewportHeight))
1484
1575
  continue;
1485
1576
  }
1486
- const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas;
1487
- const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices;
1488
- pushGlyph(vertices, (ORIGIN_X + instance.x) * scale, (ORIGIN_Y + row * LINE_HEIGHT + instance.y) * scale, instance.width * scale, instance.height * scale, atlas.glyph(definition), foreground, viewportWidth, viewportHeight);
1577
+ const mono = definition.format === GlyphFormat.Alpha8;
1578
+ const atlas = mono ? this.#monoAtlas : this.#colorAtlas;
1579
+ const vertices = mono ? glyphVertices : colorGlyphVertices;
1580
+ pushGlyph(vertices, (ORIGIN_X + instance.x) * scale, (ORIGIN_Y + row * LINE_HEIGHT + instance.y) * scale, instance.width * scale, instance.height * scale, atlas.glyph(definition), mono && foreground === style.foreground ? style.text : foreground, viewportWidth, viewportHeight);
1489
1581
  }
1490
1582
  }
1491
1583
  }
@@ -1517,10 +1609,10 @@ export class WebGpuTerminalRenderer {
1517
1609
  const rowTop = (ORIGIN_Y + row * LINE_HEIGHT) * scale;
1518
1610
  const stroke = Math.max(1, Math.round(scale));
1519
1611
  if (style.underline) {
1520
- pushRectangle(rectangleVertices, x, Math.round(rowTop + 16 * scale), width, stroke, style.foreground, viewportWidth, viewportHeight);
1612
+ pushRectangle(rectangleVertices, x, Math.round(rowTop + 16 * scale), width, stroke, style.text, viewportWidth, viewportHeight);
1521
1613
  }
1522
1614
  if (style.strikethrough) {
1523
- pushRectangle(rectangleVertices, x, Math.round(rowTop + 9 * scale), width, stroke, style.foreground, viewportWidth, viewportHeight);
1615
+ pushRectangle(rectangleVertices, x, Math.round(rowTop + 9 * scale), width, stroke, style.text, viewportWidth, viewportHeight);
1524
1616
  }
1525
1617
  }
1526
1618
  }
@@ -1684,7 +1776,7 @@ export class WebGpuTerminalRenderer {
1684
1776
  this.#colorAtlas.generation,
1685
1777
  this.#fallbackAtlas.generation,
1686
1778
  ];
1687
- let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations());
1779
+ let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations(), surface.adaptation?.key ?? "-");
1688
1780
  const cached = surface.geometryCache.get(key);
1689
1781
  const cacheHit = cached !== undefined;
1690
1782
  if (cached) {
@@ -1695,8 +1787,8 @@ export class WebGpuTerminalRenderer {
1695
1787
  else {
1696
1788
  const admitted = surface.geometryCandidates.has(key);
1697
1789
  const candidateKey = key;
1698
- const cpu = this.#buildGeometry(view, damage.rows, rowCount, false, hasNativeRows, scale, viewportWidth, viewportHeight);
1699
- key = geometryCacheKey(view, damage.rows, hasNativeRows, generations());
1790
+ const cpu = this.#buildGeometry(view, damage.rows, rowCount, false, hasNativeRows, scale, viewportWidth, viewportHeight, surface.adaptation);
1791
+ key = geometryCacheKey(view, damage.rows, hasNativeRows, generations(), surface.adaptation?.key ?? "-");
1700
1792
  const promote = admitted && key === candidateKey;
1701
1793
  geometry = {
1702
1794
  rectangleBuffer: promote