@vibecook/ghosttea-react 0.12.0 → 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 (67) hide show
  1. package/README.md +59 -4
  2. package/dist/TerminalSurface.d.ts +53 -0
  3. package/dist/TerminalSurface.d.ts.map +1 -1
  4. package/dist/TerminalSurface.js +76 -9
  5. package/dist/TerminalSurface.js.map +1 -1
  6. package/dist/appearance/AppearanceSettings.d.ts +2 -0
  7. package/dist/appearance/AppearanceSettings.d.ts.map +1 -1
  8. package/dist/appearance/AppearanceSettings.js +12 -2
  9. package/dist/appearance/AppearanceSettings.js.map +1 -1
  10. package/dist/appearance/types.d.ts +4 -0
  11. package/dist/appearance/types.d.ts.map +1 -1
  12. package/dist/catalog-budget.d.ts +2 -2
  13. package/dist/catalog-budget.d.ts.map +1 -1
  14. package/dist/catalog-budget.js +2 -2
  15. package/dist/catalog-budget.js.map +1 -1
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +2 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/renderers/glyph-atlas.d.ts +43 -0
  24. package/dist/renderers/glyph-atlas.d.ts.map +1 -0
  25. package/dist/renderers/glyph-atlas.js +175 -0
  26. package/dist/renderers/glyph-atlas.js.map +1 -0
  27. package/dist/renderers/light-adaptation.d.ts +36 -0
  28. package/dist/renderers/light-adaptation.d.ts.map +1 -0
  29. package/dist/renderers/light-adaptation.js +332 -0
  30. package/dist/renderers/light-adaptation.js.map +1 -0
  31. package/dist/renderers/types.d.ts +21 -0
  32. package/dist/renderers/types.d.ts.map +1 -1
  33. package/dist/renderers/types.js.map +1 -1
  34. package/dist/renderers/webgpu-renderer.d.ts +4 -0
  35. package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
  36. package/dist/renderers/webgpu-renderer.js +321 -237
  37. package/dist/renderers/webgpu-renderer.js.map +1 -1
  38. package/dist/routed-control.d.ts +26 -2
  39. package/dist/routed-control.d.ts.map +1 -1
  40. package/dist/routed-control.js +145 -27
  41. package/dist/routed-control.js.map +1 -1
  42. package/dist/routed-frames.d.ts +1 -1
  43. package/dist/routed-frames.d.ts.map +1 -1
  44. package/dist/routed-frames.js +3 -7
  45. package/dist/routed-frames.js.map +1 -1
  46. package/dist/row-damage.d.ts +21 -0
  47. package/dist/row-damage.d.ts.map +1 -0
  48. package/dist/row-damage.js +76 -0
  49. package/dist/row-damage.js.map +1 -0
  50. package/dist/runtime.d.ts +92 -3
  51. package/dist/runtime.d.ts.map +1 -1
  52. package/dist/runtime.js +439 -43
  53. package/dist/runtime.js.map +1 -1
  54. package/dist/styles.css +11 -0
  55. package/dist/terminal-render.worker.js +908 -288
  56. package/dist/terminal-render.worker.js.map +4 -4
  57. package/dist/worker-messages.d.ts +22 -1
  58. package/dist/worker-messages.d.ts.map +1 -1
  59. package/dist/workspace/Workspace.d.ts +15 -2
  60. package/dist/workspace/Workspace.d.ts.map +1 -1
  61. package/dist/workspace/Workspace.js +36 -12
  62. package/dist/workspace/Workspace.js.map +1 -1
  63. package/dist/workspace/pane-layout.d.ts +3 -1
  64. package/dist/workspace/pane-layout.d.ts.map +1 -1
  65. package/dist/workspace/pane-layout.js +16 -4
  66. package/dist/workspace/pane-layout.js.map +1 -1
  67. package/package.json +4 -4
@@ -6,7 +6,8 @@ import { graphemeCellWidth, splitGraphemes } from "../cell-width.js";
6
6
  import { backgroundRunBounds } from "./background-geometry.js";
7
7
  import { rowsForDamage } from "./render-damage.js";
8
8
  import { SHADER_EFFECT_WGSL } from "./shader-effects.js";
9
- const ATLAS_SIZE = 2048;
9
+ import { createLightAdaptation, darkPaintedBase, ensureContrast } from "./light-adaptation.js";
10
+ import { AtlasBatchFullError, AtlasCapacityError, GlyphAtlasPages } from "./glyph-atlas.js";
10
11
  const GEOMETRY_CACHE_LIMIT = 8;
11
12
  const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
12
13
  const EFFECT_MODES = {
@@ -180,74 +181,29 @@ class DynamicVertexBuffer {
180
181
  }
181
182
  class FallbackGlyphAtlas {
182
183
  device;
183
- #texture;
184
- #sampler;
185
- #bindGroup;
186
- #cache = new Map();
184
+ pages;
187
185
  #scratch = new OffscreenCanvas(1, 1);
188
- #x = 1;
189
- #y = 1;
190
- #rowHeight = 0;
191
186
  #uploadBytes = 0;
192
187
  #uploadCalls = 0;
193
- #generation = 0;
194
188
  constructor(device, bindGroupLayout) {
195
189
  this.device = device;
196
- this.#texture = device.createTexture({
197
- label: "terminal glyph atlas",
198
- size: [ATLAS_SIZE, ATLAS_SIZE],
199
- format: "rgba8unorm",
200
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
201
- });
202
- this.#sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" });
203
- this.#bindGroup = device.createBindGroup({
204
- label: "terminal glyph atlas bindings",
205
- layout: bindGroupLayout,
206
- entries: [
207
- { binding: 0, resource: this.#texture.createView() },
208
- { binding: 1, resource: this.#sampler },
209
- ],
210
- });
190
+ this.pages = new GlyphAtlasPages(device, bindGroupLayout, "terminal diagnostic glyph atlas", "rgba8unorm", 1);
211
191
  }
212
192
  get bindGroup() {
213
- return this.#bindGroup;
193
+ return this.pages.bindGroup;
214
194
  }
215
195
  get generation() {
216
- return this.#generation;
196
+ return this.pages.generation;
217
197
  }
218
198
  prepare(values, dpr) {
219
199
  const scale = Math.max(1, Math.min(3, dpr));
220
- const all = [...new Set(values)].filter((value) => value.trim().length > 0);
221
- const missing = all.filter((value) => !this.#cache.has(`${scale.toFixed(2)}:${value}`));
222
- if (this.#fits(missing, scale, this.#x, this.#y, this.#rowHeight))
223
- return;
224
- this.#cache.clear();
225
- this.#generation += 1;
226
- this.#x = 1;
227
- this.#y = 1;
228
- this.#rowHeight = 0;
229
- if (!this.#fits(all, scale, this.#x, this.#y, this.#rowHeight)) {
230
- throw new Error("The diagnostic glyph atlas cannot fit the text visible in one frame");
231
- }
232
- }
233
- #fits(values, scale, startX, startY, startRowHeight) {
234
- let x = startX;
235
- let y = startY;
236
- let rowHeight = startRowHeight;
237
- for (const value of values) {
238
- const width = Math.max(1, Math.ceil(CELL_WIDTH * graphemeCellWidth(value) * scale));
239
- const height = Math.max(1, Math.ceil(LINE_HEIGHT * scale));
240
- if (x + width + 1 > ATLAS_SIZE) {
241
- x = 1;
242
- y += rowHeight + 1;
243
- rowHeight = 0;
244
- }
245
- if (y + height + 1 > ATLAS_SIZE)
246
- return false;
247
- x += width + 1;
248
- rowHeight = Math.max(rowHeight, height);
249
- }
250
- 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
+ })));
251
207
  }
252
208
  glyph(grapheme, dpr) {
253
209
  if (grapheme.trim().length === 0)
@@ -255,19 +211,11 @@ class FallbackGlyphAtlas {
255
211
  const scale = Math.max(1, Math.min(3, dpr));
256
212
  const cells = graphemeCellWidth(grapheme);
257
213
  const key = `${scale.toFixed(2)}:${grapheme}`;
258
- const cached = this.#cache.get(key);
214
+ const cached = this.pages.get(key);
259
215
  if (cached)
260
216
  return cached;
261
217
  const width = Math.max(1, Math.ceil(CELL_WIDTH * cells * scale));
262
218
  const height = Math.max(1, Math.ceil(LINE_HEIGHT * scale));
263
- if (this.#x + width + 1 > ATLAS_SIZE) {
264
- this.#x = 1;
265
- this.#y += this.#rowHeight + 1;
266
- this.#rowHeight = 0;
267
- }
268
- if (this.#y + height + 1 > ATLAS_SIZE) {
269
- throw new Error("Temporary glyph atlas exhausted");
270
- }
271
219
  this.#scratch.width = width;
272
220
  this.#scratch.height = height;
273
221
  const context = this.#scratch.getContext("2d", { willReadFrequently: true });
@@ -279,23 +227,17 @@ class FallbackGlyphAtlas {
279
227
  context.textBaseline = "top";
280
228
  context.fillText(grapheme, 0, Math.max(0, Math.floor((height - 15 * scale) / 2)));
281
229
  const image = context.getImageData(0, 0, width, height);
282
- 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
+ ]);
283
235
  this.#uploadBytes += image.data.byteLength;
284
236
  this.#uploadCalls += 1;
285
- const location = {
286
- u0: this.#x / ATLAS_SIZE,
287
- v0: this.#y / ATLAS_SIZE,
288
- u1: (this.#x + width) / ATLAS_SIZE,
289
- v1: (this.#y + height) / ATLAS_SIZE,
290
- cells,
291
- };
292
- this.#cache.set(key, location);
293
- this.#x += width + 1;
294
- this.#rowHeight = Math.max(this.#rowHeight, height);
295
237
  return location;
296
238
  }
297
239
  destroy() {
298
- this.#texture.destroy();
240
+ this.pages.destroy();
299
241
  }
300
242
  uploadMetrics() {
301
243
  return { bytes: this.#uploadBytes, calls: this.#uploadCalls };
@@ -305,111 +247,44 @@ class NativeGlyphAtlas {
305
247
  device;
306
248
  label;
307
249
  format;
308
- #texture;
309
- #sampler;
310
- #bindGroup;
311
- #cache = new Map();
312
- #x = 1;
313
- #y = 1;
314
- #rowHeight = 0;
250
+ pages;
315
251
  #uploadBytes = 0;
316
252
  #uploadCalls = 0;
317
- #generation = 0;
318
253
  constructor(device, bindGroupLayout, label, format) {
319
254
  this.device = device;
320
255
  this.label = label;
321
256
  this.format = format;
322
- this.#texture = device.createTexture({
323
- label,
324
- size: [ATLAS_SIZE, ATLAS_SIZE],
325
- format,
326
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
327
- });
328
- this.#sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" });
329
- this.#bindGroup = device.createBindGroup({
330
- label: `${label} bindings`,
331
- layout: bindGroupLayout,
332
- entries: [
333
- { binding: 0, resource: this.#texture.createView() },
334
- { binding: 1, resource: this.#sampler },
335
- ],
336
- });
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);
337
260
  }
338
261
  get bindGroup() {
339
- return this.#bindGroup;
262
+ return this.pages.bindGroup;
340
263
  }
341
264
  get generation() {
342
- return this.#generation;
265
+ return this.pages.generation;
343
266
  }
344
267
  prepare(definitions) {
345
- const all = new Map();
346
- const missing = new Map();
347
- for (const definition of definitions) {
348
- all.set(definition.id, definition);
349
- if (!this.#cache.has(definition.id))
350
- missing.set(definition.id, definition);
351
- }
352
- if (this.#fits(missing.values(), this.#x, this.#y, this.#rowHeight))
353
- return;
354
- this.#cache.clear();
355
- this.#generation += 1;
356
- this.#x = 1;
357
- this.#y = 1;
358
- this.#rowHeight = 0;
359
- if (!this.#fits(all.values(), this.#x, this.#y, this.#rowHeight)) {
360
- throw new Error(`${this.label} cannot fit the glyphs visible in one frame`);
361
- }
362
- }
363
- #fits(definitions, startX, startY, startRowHeight) {
364
- let x = startX;
365
- let y = startY;
366
- let rowHeight = startRowHeight;
367
- for (const { width, height } of definitions) {
368
- if (width + 2 > ATLAS_SIZE || height + 2 > ATLAS_SIZE)
369
- return false;
370
- if (x + width + 1 > ATLAS_SIZE) {
371
- x = 1;
372
- y += rowHeight + 1;
373
- rowHeight = 0;
374
- }
375
- if (y + height + 1 > ATLAS_SIZE)
376
- return false;
377
- x += width + 1;
378
- rowHeight = Math.max(rowHeight, height);
379
- }
380
- return true;
268
+ this.pages.prepare(Array.from(definitions, (definition) => ({
269
+ key: definition.id,
270
+ width: definition.width,
271
+ height: definition.height,
272
+ })));
381
273
  }
382
274
  glyph(definition) {
383
- const cached = this.#cache.get(definition.id);
275
+ const cached = this.pages.get(definition.id);
384
276
  if (cached)
385
277
  return cached;
386
278
  const { width, height } = definition;
387
- if (this.#x + width + 1 > ATLAS_SIZE) {
388
- this.#x = 1;
389
- this.#y += this.#rowHeight + 1;
390
- this.#rowHeight = 0;
391
- }
392
- if (this.#y + height + 1 > ATLAS_SIZE)
393
- throw new Error(`${this.label} exhausted`);
394
- const pixels = definition.pixels;
279
+ const { location, texture, origin } = this.pages.insert(definition.id, width, height, 0);
395
280
  const bytesPerPixel = this.format === "r8unorm" ? 1 : 4;
396
- this.device.queue.writeTexture({ texture: this.#texture, origin: [this.#x, this.#y] }, pixels, { bytesPerRow: width * bytesPerPixel, rowsPerImage: height }, [width, height]);
397
- 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;
398
283
  this.#uploadCalls += 1;
399
- const location = {
400
- u0: this.#x / ATLAS_SIZE,
401
- v0: this.#y / ATLAS_SIZE,
402
- u1: (this.#x + width) / ATLAS_SIZE,
403
- v1: (this.#y + height) / ATLAS_SIZE,
404
- cells: 0,
405
- };
406
- this.#cache.set(definition.id, location);
407
- this.#x += width + 1;
408
- this.#rowHeight = Math.max(this.#rowHeight, height);
409
284
  return location;
410
285
  }
411
286
  destroy() {
412
- this.#texture.destroy();
287
+ this.pages.destroy();
413
288
  }
414
289
  uploadMetrics() {
415
290
  return { bytes: this.#uploadBytes, calls: this.#uploadCalls };
@@ -438,7 +313,7 @@ function createCachedVertexBuffer(device, label, data) {
438
313
  device.queue.writeBuffer(buffer, 0, data);
439
314
  return buffer;
440
315
  }
441
- function geometryCacheKey(view, rows, hasNativeRows, atlasGenerations) {
316
+ function geometryCacheKey(view, rows, hasNativeRows, atlasGenerations, adaptationKey) {
442
317
  const selection = view.selection
443
318
  ? `${view.selection.anchor.row},${view.selection.anchor.column},${view.selection.focus.row},${view.selection.focus.column}`
444
319
  : "-";
@@ -457,6 +332,8 @@ function geometryCacheKey(view, rows, hasNativeRows, atlasGenerations) {
457
332
  ...view.theme.selectionForeground,
458
333
  ].join(","),
459
334
  view.theme.backgroundOpacityCells ? "opacity-cells" : "opaque-cells",
335
+ view.theme.minimumContrast ?? 1,
336
+ adaptationKey,
460
337
  selection,
461
338
  effectiveCursorStyle(view) === CursorStyle.Block ? `${view.cursor.x},${view.cursor.y}` : "-",
462
339
  atlasGenerations.join(","),
@@ -659,22 +536,51 @@ function pushBlockCursorBackground(vertices, view, renderRows, scale, viewportWi
659
536
  return;
660
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);
661
538
  }
662
- function resolveStyle(style, theme) {
539
+ function resolveStyle(style, theme, adaptation = null) {
663
540
  let foreground = style?.foreground ? rgb(style.foreground) : theme.foreground;
664
541
  let background = style?.background ? rgb(style.background) : null;
665
- 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) {
666
563
  const originalForeground = foreground;
667
564
  foreground = background ?? theme.background;
668
565
  background = originalForeground;
566
+ fill = foreground;
669
567
  }
670
568
  if (background && theme.backgroundOpacityCells) {
671
569
  background = [background[0], background[1], background[2], theme.background[3]];
672
570
  }
673
- 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) {
674
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
+ }
675
579
  return {
676
580
  foreground,
677
581
  background,
582
+ fill,
583
+ text,
678
584
  underline: style?.underline ?? false,
679
585
  strikethrough: style?.strikethrough ?? false,
680
586
  invisible: style?.invisible ?? false,
@@ -821,7 +727,10 @@ export class WebGpuTerminalRenderer {
821
727
  #monoAtlas;
822
728
  #colorAtlas;
823
729
  #fallbackAtlas;
730
+ #adaptations = new Map();
824
731
  #performanceMeasurementEnabled = false;
732
+ #submission = { queueSubmits: 0, surfaceIds: [] };
733
+ #scrollScratch;
825
734
  constructor(device, format) {
826
735
  this.device = device;
827
736
  this.format = format;
@@ -1020,6 +929,10 @@ export class WebGpuTerminalRenderer {
1020
929
  geometryCache: new Map(),
1021
930
  geometryCandidates: new Map(),
1022
931
  sceneValid: false,
932
+ adaptation: null,
933
+ sceneHadSelection: false,
934
+ sceneHadOverhang: false,
935
+ sceneCursorRow: null,
1023
936
  };
1024
937
  this.#surfaces.set(id, surface);
1025
938
  this.#configure(surface);
@@ -1041,6 +954,10 @@ export class WebGpuTerminalRenderer {
1041
954
  surface.cursorBuffer.destroy();
1042
955
  clearGeometryCache(surface);
1043
956
  this.#surfaces.delete(id);
957
+ if (this.#surfaces.size === 0) {
958
+ this.#scrollScratch?.destroy();
959
+ this.#scrollScratch = undefined;
960
+ }
1044
961
  }
1045
962
  resize(id, size) {
1046
963
  const surface = this.#surfaces.get(id);
@@ -1073,7 +990,10 @@ export class WebGpuTerminalRenderer {
1073
990
  label: "terminal scene before Ghostty custom shaders",
1074
991
  size: [Math.max(1, width), Math.max(1, height)],
1075
992
  format: this.format,
1076
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
993
+ usage: GPUTextureUsage.RENDER_ATTACHMENT |
994
+ GPUTextureUsage.TEXTURE_BINDING |
995
+ GPUTextureUsage.COPY_SRC |
996
+ GPUTextureUsage.COPY_DST,
1077
997
  });
1078
998
  }
1079
999
  #createEffectTexture(width, height, suffix) {
@@ -1190,37 +1110,227 @@ export class WebGpuTerminalRenderer {
1190
1110
  drawCalls: effectPassCount,
1191
1111
  };
1192
1112
  }
1193
- render(id, view) {
1194
- const surface = this.#surfaces.get(id);
1195
- 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))
1196
1131
  return;
1197
- const encoder = this.device.createCommandEncoder({ label: `terminal frame ${id}` });
1198
- const metrics = view.damage?.geometryChanged === false
1199
- ? this.#encodeCachedRender(id, view, encoder)
1200
- : this.#encodeRender(id, view, encoder);
1201
- this.device.queue.submit([encoder.finish()]);
1202
- if (metrics)
1203
- metrics.queueSubmits = 1;
1204
- 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);
1205
1222
  }
1206
1223
  renderBatch(entries) {
1207
- const active = entries.filter(({ id }) => this.#surfaces.has(id));
1208
- if (active.length === 0)
1209
- return entries.map(() => undefined);
1210
- 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 = [];
1211
1249
  const byId = new Map();
1212
- for (const { id, view } of active) {
1213
- byId.set(id, view.damage?.geometryChanged === false
1214
- ? this.#encodeCachedRender(id, view, encoder)
1215
- : 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();
1216
1330
  }
1217
- this.device.queue.submit([encoder.finish()]);
1218
- const firstMetrics = [...byId.values()].find((metrics) => metrics !== undefined);
1219
- if (firstMetrics)
1220
- firstMetrics.queueSubmits = 1;
1221
1331
  return entries.map(({ id }) => byId.get(id));
1222
1332
  }
1223
- #buildGeometry(view, renderRows, rowCount, fullRedraw, hasNativeRows, scale, viewportWidth, viewportHeight) {
1333
+ #buildGeometry(view, renderRows, rowCount, fullRedraw, hasNativeRows, scale, viewportWidth, viewportHeight, adaptation) {
1224
1334
  const rectangleVertices = [];
1225
1335
  const glyphVertices = [];
1226
1336
  const colorGlyphVertices = [];
@@ -1230,22 +1340,10 @@ export class WebGpuTerminalRenderer {
1230
1340
  const cached = resolvedStyles.get(styleId);
1231
1341
  if (cached)
1232
1342
  return cached;
1233
- const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme);
1343
+ const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, adaptation);
1234
1344
  resolvedStyles.set(styleId, resolved);
1235
1345
  return resolved;
1236
1346
  };
1237
- const monoDefinitions = new Map();
1238
- const colorDefinitions = new Map();
1239
- for (const row of renderRows) {
1240
- for (const glyph of view.nativeRows[row] ?? []) {
1241
- const definition = view.glyphDefinitions.get(glyph.glyphId);
1242
- if (!definition)
1243
- continue;
1244
- (definition.format === GlyphFormat.Rgba8Premultiplied ? colorDefinitions : monoDefinitions).set(definition.id, definition);
1245
- }
1246
- }
1247
- this.#monoAtlas.prepare(monoDefinitions.values());
1248
- this.#colorAtlas.prepare(colorDefinitions.values());
1249
1347
  if (!fullRedraw) {
1250
1348
  for (const row of renderRows) {
1251
1349
  const top = row === 0 ? 0 : (ORIGIN_Y + row * LINE_HEIGHT) * scale;
@@ -1281,9 +1379,6 @@ export class WebGpuTerminalRenderer {
1281
1379
  }
1282
1380
  pushBlockCursorBackground(rectangleVertices, view, renderRows, scale, viewportWidth, viewportHeight);
1283
1381
  const selectionInstanceCount = rectangleVertices.length / 10 - backgroundInstanceCount;
1284
- if (!hasNativeRows) {
1285
- this.#fallbackAtlas.prepare(renderRows.flatMap((row) => splitGraphemes(view.rows[row] ?? "")), scale);
1286
- }
1287
1382
  if (hasNativeRows) {
1288
1383
  for (const row of renderRows) {
1289
1384
  const { box: boxCells, block: blockCells } = specialDrawingCells(view.rows[row] ?? "");
@@ -1315,12 +1410,14 @@ export class WebGpuTerminalRenderer {
1315
1410
  let backdrop = style.background ?? view.theme.background;
1316
1411
  if (isSelected)
1317
1412
  backdrop = view.theme.selection;
1318
- 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))
1319
1415
  continue;
1320
1416
  }
1321
- const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas;
1322
- const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices;
1323
- 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);
1324
1421
  }
1325
1422
  }
1326
1423
  }
@@ -1352,10 +1449,10 @@ export class WebGpuTerminalRenderer {
1352
1449
  const rowTop = (ORIGIN_Y + row * LINE_HEIGHT) * scale;
1353
1450
  const stroke = Math.max(1, Math.round(scale));
1354
1451
  if (style.underline) {
1355
- 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);
1356
1453
  }
1357
1454
  if (style.strikethrough) {
1358
- 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);
1359
1456
  }
1360
1457
  }
1361
1458
  }
@@ -1399,22 +1496,10 @@ export class WebGpuTerminalRenderer {
1399
1496
  const cached = resolvedStyles.get(styleId);
1400
1497
  if (cached)
1401
1498
  return cached;
1402
- const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme);
1499
+ const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, surface.adaptation);
1403
1500
  resolvedStyles.set(styleId, resolved);
1404
1501
  return resolved;
1405
1502
  };
1406
- const monoDefinitions = new Map();
1407
- const colorDefinitions = new Map();
1408
- for (const row of renderRows) {
1409
- for (const glyph of view.nativeRows[row] ?? []) {
1410
- const definition = view.glyphDefinitions.get(glyph.glyphId);
1411
- if (!definition)
1412
- continue;
1413
- (definition.format === GlyphFormat.Rgba8Premultiplied ? colorDefinitions : monoDefinitions).set(definition.id, definition);
1414
- }
1415
- }
1416
- this.#monoAtlas.prepare(monoDefinitions.values());
1417
- this.#colorAtlas.prepare(colorDefinitions.values());
1418
1503
  if (!fullRedraw) {
1419
1504
  for (const row of renderRows) {
1420
1505
  const top = row === 0 ? 0 : (ORIGIN_Y + row * LINE_HEIGHT) * scale;
@@ -1451,9 +1536,6 @@ export class WebGpuTerminalRenderer {
1451
1536
  pushBlockCursorBackground(rectangleVertices, view, renderRows, scale, viewportWidth, viewportHeight);
1452
1537
  const selectionInstanceCount = rectangleVertices.length / 10 - backgroundInstanceCount;
1453
1538
  const hasNativeRows = view.nativeRows.some((row) => row.length > 0);
1454
- if (!hasNativeRows) {
1455
- this.#fallbackAtlas.prepare(renderRows.flatMap((row) => splitGraphemes(view.rows[row] ?? "")), scale);
1456
- }
1457
1539
  if (hasNativeRows) {
1458
1540
  for (const row of renderRows) {
1459
1541
  const { box: boxCells, block: blockCells } = specialDrawingCells(view.rows[row] ?? "");
@@ -1487,13 +1569,15 @@ export class WebGpuTerminalRenderer {
1487
1569
  let backdrop = style.background ?? view.theme.background;
1488
1570
  if (isSelected)
1489
1571
  backdrop = view.theme.selection;
1490
- const blockColor = over(foreground, backdrop);
1572
+ const blockForeground = foreground === style.foreground ? style.fill : foreground;
1573
+ const blockColor = over(blockForeground, backdrop);
1491
1574
  if (pushBlockElement(rectangleVertices, blockElement, instance.cellStart, row, scale, blockColor, viewportWidth, viewportHeight))
1492
1575
  continue;
1493
1576
  }
1494
- const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas;
1495
- const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices;
1496
- 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);
1497
1581
  }
1498
1582
  }
1499
1583
  }
@@ -1525,10 +1609,10 @@ export class WebGpuTerminalRenderer {
1525
1609
  const rowTop = (ORIGIN_Y + row * LINE_HEIGHT) * scale;
1526
1610
  const stroke = Math.max(1, Math.round(scale));
1527
1611
  if (style.underline) {
1528
- 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);
1529
1613
  }
1530
1614
  if (style.strikethrough) {
1531
- 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);
1532
1616
  }
1533
1617
  }
1534
1618
  }
@@ -1692,7 +1776,7 @@ export class WebGpuTerminalRenderer {
1692
1776
  this.#colorAtlas.generation,
1693
1777
  this.#fallbackAtlas.generation,
1694
1778
  ];
1695
- let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations());
1779
+ let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations(), surface.adaptation?.key ?? "-");
1696
1780
  const cached = surface.geometryCache.get(key);
1697
1781
  const cacheHit = cached !== undefined;
1698
1782
  if (cached) {
@@ -1703,8 +1787,8 @@ export class WebGpuTerminalRenderer {
1703
1787
  else {
1704
1788
  const admitted = surface.geometryCandidates.has(key);
1705
1789
  const candidateKey = key;
1706
- const cpu = this.#buildGeometry(view, damage.rows, rowCount, false, hasNativeRows, scale, viewportWidth, viewportHeight);
1707
- 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 ?? "-");
1708
1792
  const promote = admitted && key === candidateKey;
1709
1793
  geometry = {
1710
1794
  rectangleBuffer: promote