@vibecook/ghosttea-react 0.8.0 → 0.9.1

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 (65) hide show
  1. package/README.md +56 -0
  2. package/THIRD_PARTY_NOTICES.md +62 -0
  3. package/dist/appearance/AdvancedConfigSettings.d.ts +12 -0
  4. package/dist/appearance/AdvancedConfigSettings.d.ts.map +1 -0
  5. package/dist/appearance/AdvancedConfigSettings.js +362 -0
  6. package/dist/appearance/AdvancedConfigSettings.js.map +1 -0
  7. package/dist/appearance/AppearanceSettings.d.ts +21 -0
  8. package/dist/appearance/AppearanceSettings.d.ts.map +1 -0
  9. package/dist/appearance/AppearanceSettings.js +175 -0
  10. package/dist/appearance/AppearanceSettings.js.map +1 -0
  11. package/dist/appearance/catalog.d.ts +39 -0
  12. package/dist/appearance/catalog.d.ts.map +1 -0
  13. package/dist/appearance/catalog.js +56 -0
  14. package/dist/appearance/catalog.js.map +1 -0
  15. package/dist/appearance/config-draft.d.ts +35 -0
  16. package/dist/appearance/config-draft.d.ts.map +1 -0
  17. package/dist/appearance/config-draft.js +289 -0
  18. package/dist/appearance/config-draft.js.map +1 -0
  19. package/dist/appearance/shaders.d.ts +14 -0
  20. package/dist/appearance/shaders.d.ts.map +1 -0
  21. package/dist/appearance/shaders.js +71 -0
  22. package/dist/appearance/shaders.js.map +1 -0
  23. package/dist/appearance/theme-catalog.generated.json +16260 -0
  24. package/dist/appearance/types.d.ts +61 -0
  25. package/dist/appearance/types.d.ts.map +1 -0
  26. package/dist/appearance/types.js +2 -0
  27. package/dist/appearance/types.js.map +1 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +23 -4
  30. package/dist/config.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/renderers/canvas-renderer.d.ts.map +1 -1
  36. package/dist/renderers/canvas-renderer.js +9 -1
  37. package/dist/renderers/canvas-renderer.js.map +1 -1
  38. package/dist/renderers/shader-effects.d.ts +2 -0
  39. package/dist/renderers/shader-effects.d.ts.map +1 -0
  40. package/dist/renderers/shader-effects.js +206 -0
  41. package/dist/renderers/shader-effects.js.map +1 -0
  42. package/dist/renderers/types.d.ts +7 -0
  43. package/dist/renderers/types.d.ts.map +1 -1
  44. package/dist/renderers/types.js +11 -0
  45. package/dist/renderers/types.js.map +1 -1
  46. package/dist/renderers/webgpu-renderer.d.ts +2 -1
  47. package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
  48. package/dist/renderers/webgpu-renderer.js +250 -159
  49. package/dist/renderers/webgpu-renderer.js.map +1 -1
  50. package/dist/terminal-render.worker.js +479 -165
  51. package/dist/terminal-render.worker.js.map +3 -3
  52. package/dist/workspace/Workspace.d.ts +23 -4
  53. package/dist/workspace/Workspace.d.ts.map +1 -1
  54. package/dist/workspace/Workspace.js +30 -8
  55. package/dist/workspace/Workspace.js.map +1 -1
  56. package/dist/workspace/index.d.ts +3 -0
  57. package/dist/workspace/index.d.ts.map +1 -1
  58. package/dist/workspace/index.js +2 -0
  59. package/dist/workspace/index.js.map +1 -1
  60. package/dist/workspace/themes.d.ts +4 -0
  61. package/dist/workspace/themes.d.ts.map +1 -1
  62. package/dist/workspace/themes.js +4 -0
  63. package/dist/workspace/themes.js.map +1 -1
  64. package/dist/workspace.css +1122 -5
  65. package/package.json +6 -5
@@ -1,11 +1,22 @@
1
1
  /// <reference types="@webgpu/types" />
2
2
  import { CursorStyle, GlyphFormat } from "@vibecook/ghosttea-frame";
3
+ import { emptyRenderMetrics } from "../performance.js";
3
4
  import { CELL_WIDTH, LINE_HEIGHT, ORIGIN_X, ORIGIN_Y, effectiveCursorStyle, renderedSizeChanged, } from "./types.js";
4
5
  import { graphemeCellWidth, splitGraphemes } from "../cell-width.js";
5
6
  import { rowsForDamage } from "./render-damage.js";
7
+ import { SHADER_EFFECT_WGSL } from "./shader-effects.js";
6
8
  const ATLAS_SIZE = 2048;
7
9
  const GEOMETRY_CACHE_LIMIT = 8;
8
10
  const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
11
+ const EFFECT_MODES = {
12
+ "ghosttea:better-crt": 1,
13
+ "ghosttea:crt": 2,
14
+ "ghosttea:vhs": 3,
15
+ "ghosttea:sparks-from-fire": 4,
16
+ };
17
+ export function effectIntermediateTextureCount(effectPassCount) {
18
+ return Math.min(2, Math.max(0, Math.trunc(effectPassCount) - 1));
19
+ }
9
20
  const RECT_SHADER = /* wgsl */ `
10
21
  struct VertexOutput {
11
22
  @builtin(position) position: vec4f,
@@ -130,54 +141,13 @@ struct VertexOutput {
130
141
  return textureSample(atlas, atlas_sampler, input.uv);
131
142
  }
132
143
  `;
133
- // WebGPU port of ~/.config/ghostty/shaders/bettercrt.glsl. Ghostty applies
134
- // custom shaders to the completed terminal image, so this must remain a
135
- // separate pass rather than being approximated per glyph.
136
- const BETTER_CRT_SHADER = /* wgsl */ `
137
- @group(0) @binding(0) var terminal_image: texture_2d<f32>;
138
- @group(0) @binding(1) var terminal_sampler: sampler;
139
- struct PostProcessConfig {
140
- // A vec4 keeps the WGSL uniform layout equal to the host's 16-byte buffer.
141
- enabled: vec4u,
142
- }
143
- @group(0) @binding(2) var<uniform> post_process: PostProcessConfig;
144
-
145
- @vertex fn vertex_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4f {
146
- let positions = array<vec2f, 3>(
147
- vec2f(-1.0, -1.0),
148
- vec2f(3.0, -1.0),
149
- vec2f(-1.0, 3.0),
150
- );
151
- return vec4f(positions[vertex_index], 0.0, 1.0);
152
- }
153
-
154
- @fragment fn fragment_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
155
- let dimensions = vec2f(textureDimensions(terminal_image));
156
- var uv = position.xy / dimensions;
157
- if (post_process.enabled.x == 0u) {
158
- return textureSample(terminal_image, terminal_sampler, uv);
159
- }
160
- var dc = abs(vec2f(0.5) - uv);
161
- dc *= dc;
162
-
163
- // These constants are the active Ghostty shader's warp = 0.25 and
164
- // scan = 0.50 values.
165
- uv.x -= 0.5;
166
- uv.x *= 1.0 + dc.y * 0.075;
167
- uv.x += 0.5;
168
- uv.y -= 0.5;
169
- uv.y *= 1.0 + dc.x * 0.10;
170
- uv.y += 0.5;
171
-
172
- let scanline = abs(sin(position.y) * 0.125);
173
- let color = textureSample(terminal_image, terminal_sampler, uv).rgb;
174
- return vec4f(mix(color, vec3f(0.0), scanline), 1.0);
175
- }
176
- `;
177
144
  const premultipliedBlend = {
178
145
  color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
179
146
  alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
180
147
  };
148
+ function premultipliedClear(color) {
149
+ return { r: color[0] * color[3], g: color[1] * color[3], b: color[2] * color[3], a: color[3] };
150
+ }
181
151
  class DynamicVertexBuffer {
182
152
  device;
183
153
  label;
@@ -479,16 +449,20 @@ function geometryCacheKey(view, rows, hasNativeRows, atlasGenerations) {
479
449
  [
480
450
  ...view.theme.background,
481
451
  ...view.theme.foreground,
452
+ ...view.theme.cursor,
453
+ ...(view.theme.cursorText ?? view.theme.background),
482
454
  ...view.theme.selection,
483
455
  ...view.theme.selectionForeground,
484
456
  ].join(","),
457
+ view.theme.backgroundOpacityCells ? "opacity-cells" : "opaque-cells",
485
458
  selection,
459
+ effectiveCursorStyle(view) === CursorStyle.Block ? `${view.cursor.x},${view.cursor.y}` : "-",
486
460
  atlasGenerations.join(","),
487
461
  ].join("|");
488
462
  }
489
463
  function pushCursorVertices(vertices, view, renderRowSet, scale, viewportWidth, viewportHeight) {
490
464
  const cursorStyle = effectiveCursorStyle(view);
491
- if (cursorStyle === null || !renderRowSet.has(view.cursor.y))
465
+ if (cursorStyle === null || cursorStyle === CursorStyle.Block || !renderRowSet.has(view.cursor.y))
492
466
  return;
493
467
  const x = (ORIGIN_X + view.cursor.x * CELL_WIDTH) * scale;
494
468
  const y = (ORIGIN_Y + view.cursor.y * LINE_HEIGHT) * scale;
@@ -672,6 +646,17 @@ function selectionContains(selection, row, column) {
672
646
  const last = row === end.row ? end.column : Number.MAX_SAFE_INTEGER;
673
647
  return column >= first && column <= last;
674
648
  }
649
+ function blockCursorContains(view, row, column, span = 1) {
650
+ return (effectiveCursorStyle(view) === CursorStyle.Block &&
651
+ view.cursor.y === row &&
652
+ view.cursor.x >= column &&
653
+ view.cursor.x < column + Math.max(1, span));
654
+ }
655
+ function pushBlockCursorBackground(vertices, view, renderRows, scale, viewportWidth, viewportHeight) {
656
+ if (effectiveCursorStyle(view) !== CursorStyle.Block || !renderRows.includes(view.cursor.y))
657
+ return;
658
+ 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
+ }
675
660
  function resolveStyle(style, theme) {
676
661
  let foreground = style?.foreground ? rgb(style.foreground) : theme.foreground;
677
662
  let background = style?.background ? rgb(style.background) : null;
@@ -680,6 +665,9 @@ function resolveStyle(style, theme) {
680
665
  foreground = background ?? theme.background;
681
666
  background = originalForeground;
682
667
  }
668
+ if (background && theme.backgroundOpacityCells) {
669
+ background = [background[0], background[1], background[2], theme.background[3]];
670
+ }
683
671
  if (style?.faint)
684
672
  foreground = [foreground[0], foreground[1], foreground[2], foreground[3] * 0.55];
685
673
  return {
@@ -822,11 +810,12 @@ export class WebGpuTerminalRenderer {
822
810
  kind = "webgpu";
823
811
  #surfaces = new Map();
824
812
  #rectanglePipeline;
813
+ #rectangleOverwritePipeline;
825
814
  #glyphPipeline;
826
815
  #fallbackGlyphPipeline;
827
816
  #colorGlyphPipeline;
828
- #postProcessPipeline;
829
- #postProcessSampler;
817
+ #effectPipeline;
818
+ #effectSampler;
830
819
  #monoAtlas;
831
820
  #colorAtlas;
832
821
  #fallbackAtlas;
@@ -844,7 +833,10 @@ export class WebGpuTerminalRenderer {
844
833
  label: "terminal color glyph shader",
845
834
  code: COLOR_GLYPH_SHADER,
846
835
  });
847
- const postProcessModule = device.createShaderModule({ label: "Ghostty bettercrt shader", code: BETTER_CRT_SHADER });
836
+ const effectModule = device.createShaderModule({
837
+ label: "Ghosttea shader effect registry",
838
+ code: SHADER_EFFECT_WGSL,
839
+ });
848
840
  this.#rectanglePipeline = device.createRenderPipeline({
849
841
  label: "terminal rectangle pipeline",
850
842
  layout: "auto",
@@ -871,6 +863,28 @@ export class WebGpuTerminalRenderer {
871
863
  },
872
864
  primitive: { topology: "triangle-list" },
873
865
  });
866
+ this.#rectangleOverwritePipeline = device.createRenderPipeline({
867
+ label: "terminal row-reset overwrite pipeline",
868
+ layout: "auto",
869
+ vertex: {
870
+ module: rectangleModule,
871
+ entryPoint: "vertex_main",
872
+ buffers: [
873
+ {
874
+ arrayStride: 40,
875
+ stepMode: "instance",
876
+ attributes: [
877
+ { shaderLocation: 0, offset: 0, format: "float32x2" },
878
+ { shaderLocation: 1, offset: 8, format: "float32x2" },
879
+ { shaderLocation: 2, offset: 16, format: "float32x2" },
880
+ { shaderLocation: 3, offset: 24, format: "float32x4" },
881
+ ],
882
+ },
883
+ ],
884
+ },
885
+ fragment: { module: rectangleModule, entryPoint: "fragment_main", targets: [{ format }] },
886
+ primitive: { topology: "triangle-list" },
887
+ });
874
888
  this.#glyphPipeline = device.createRenderPipeline({
875
889
  label: "terminal glyph pipeline",
876
890
  layout: "auto",
@@ -942,15 +956,15 @@ export class WebGpuTerminalRenderer {
942
956
  },
943
957
  primitive: { topology: "triangle-list" },
944
958
  });
945
- this.#postProcessPipeline = device.createRenderPipeline({
946
- label: "Ghostty custom shader pipeline",
959
+ this.#effectPipeline = device.createRenderPipeline({
960
+ label: "Ghosttea ordered shader effect pipeline",
947
961
  layout: "auto",
948
- vertex: { module: postProcessModule, entryPoint: "vertex_main" },
949
- fragment: { module: postProcessModule, entryPoint: "fragment_main", targets: [{ format }] },
962
+ vertex: { module: effectModule, entryPoint: "vertex_main" },
963
+ fragment: { module: effectModule, entryPoint: "fragment_main", targets: [{ format }] },
950
964
  primitive: { topology: "triangle-list" },
951
965
  });
952
- this.#postProcessSampler = device.createSampler({
953
- label: "Ghostty custom shader sampler",
966
+ this.#effectSampler = device.createSampler({
967
+ label: "Ghosttea shader effect sampler",
954
968
  addressModeU: "clamp-to-edge",
955
969
  addressModeV: "clamp-to-edge",
956
970
  minFilter: "linear",
@@ -984,18 +998,15 @@ export class WebGpuTerminalRenderer {
984
998
  if (!context)
985
999
  throw new Error("WebGPU canvas context unavailable");
986
1000
  const sceneTexture = this.#createSceneTexture(canvas.width, canvas.height);
987
- const postProcessConfigBuffer = this.device.createBuffer({
988
- label: `Ghostty custom shader configuration ${id}`,
989
- size: 16,
990
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
991
- });
992
1001
  const surface = {
993
1002
  canvas,
994
1003
  context,
995
1004
  sceneTexture,
996
- postProcessConfigBuffer,
997
- postProcessBindGroup: this.#createPostProcessBindGroup(sceneTexture, postProcessConfigBuffer),
998
- postProcessEnabled: undefined,
1005
+ effectTextures: [],
1006
+ effectPasses: [],
1007
+ effectSignature: "",
1008
+ effectFrame: 0,
1009
+ lastEffectTime: 0,
999
1010
  width: 1,
1000
1011
  height: 1,
1001
1012
  dpr: 1,
@@ -1017,7 +1028,10 @@ export class WebGpuTerminalRenderer {
1017
1028
  return;
1018
1029
  surface.context.unconfigure();
1019
1030
  surface.sceneTexture.destroy();
1020
- surface.postProcessConfigBuffer.destroy();
1031
+ for (const texture of surface.effectTextures)
1032
+ texture.destroy();
1033
+ for (const pass of surface.effectPasses)
1034
+ pass.configBuffer.destroy();
1021
1035
  surface.rectangleBuffer.destroy();
1022
1036
  surface.glyphBuffer.destroy();
1023
1037
  surface.colorGlyphBuffer.destroy();
@@ -1041,8 +1055,14 @@ export class WebGpuTerminalRenderer {
1041
1055
  #configure(surface) {
1042
1056
  surface.context.configure({ device: this.device, format: this.format, alphaMode: "premultiplied" });
1043
1057
  surface.sceneTexture.destroy();
1058
+ for (const texture of surface.effectTextures)
1059
+ texture.destroy();
1060
+ for (const pass of surface.effectPasses)
1061
+ pass.configBuffer.destroy();
1044
1062
  surface.sceneTexture = this.#createSceneTexture(surface.canvas.width, surface.canvas.height);
1045
- surface.postProcessBindGroup = this.#createPostProcessBindGroup(surface.sceneTexture, surface.postProcessConfigBuffer);
1063
+ surface.effectTextures = [];
1064
+ surface.effectPasses = [];
1065
+ surface.effectSignature = "";
1046
1066
  clearGeometryCache(surface);
1047
1067
  surface.sceneValid = false;
1048
1068
  }
@@ -1054,26 +1074,124 @@ export class WebGpuTerminalRenderer {
1054
1074
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1055
1075
  });
1056
1076
  }
1057
- #createPostProcessBindGroup(texture, configBuffer) {
1077
+ #createEffectTexture(width, height, suffix) {
1078
+ return this.device.createTexture({
1079
+ label: `terminal shader ping-pong ${suffix}`,
1080
+ size: [Math.max(1, width), Math.max(1, height)],
1081
+ format: this.format,
1082
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1083
+ });
1084
+ }
1085
+ #createEffectBindGroup(texture, configBuffer) {
1058
1086
  return this.device.createBindGroup({
1059
- label: "Ghostty custom shader bindings",
1060
- layout: this.#postProcessPipeline.getBindGroupLayout(0),
1087
+ label: "Ghosttea shader effect bindings",
1088
+ layout: this.#effectPipeline.getBindGroupLayout(0),
1061
1089
  entries: [
1062
1090
  { binding: 0, resource: texture.createView() },
1063
- { binding: 1, resource: this.#postProcessSampler },
1091
+ { binding: 1, resource: this.#effectSampler },
1064
1092
  { binding: 2, resource: { buffer: configBuffer } },
1065
1093
  ],
1066
1094
  });
1067
1095
  }
1096
+ #shaderStack(view) {
1097
+ const configured = view.effects?.shaderEffects?.filter((id) => EFFECT_MODES[id] !== undefined) ?? [];
1098
+ if (configured.length > 0)
1099
+ return [...configured];
1100
+ return view.effects?.postProcess === "better-crt" ? ["ghosttea:better-crt"] : [];
1101
+ }
1102
+ #ensureEffectPasses(surface, stack) {
1103
+ const signature = stack.join("\0") || "ghosttea:blit";
1104
+ if (surface.effectSignature === signature)
1105
+ return;
1106
+ for (const pass of surface.effectPasses)
1107
+ pass.configBuffer.destroy();
1108
+ const modes = stack.length > 0 ? stack.map((id) => EFFECT_MODES[id]) : [0];
1109
+ const intermediateCount = effectIntermediateTextureCount(modes.length);
1110
+ while (surface.effectTextures.length > intermediateCount)
1111
+ surface.effectTextures.pop().destroy();
1112
+ while (surface.effectTextures.length < intermediateCount) {
1113
+ const suffix = surface.effectTextures.length === 0 ? "A" : "B";
1114
+ surface.effectTextures.push(this.#createEffectTexture(surface.canvas.width, surface.canvas.height, suffix));
1115
+ }
1116
+ surface.effectPasses = modes.map((mode, index) => {
1117
+ const configBuffer = this.device.createBuffer({
1118
+ label: `Ghosttea shader effect configuration ${index}`,
1119
+ size: 48,
1120
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1121
+ });
1122
+ const inputTexture = index === 0 ? surface.sceneTexture : surface.effectTextures[(index - 1) % 2];
1123
+ return {
1124
+ mode,
1125
+ configBuffer,
1126
+ bindGroup: this.#createEffectBindGroup(inputTexture, configBuffer),
1127
+ };
1128
+ });
1129
+ surface.effectSignature = signature;
1130
+ surface.effectFrame = 0;
1131
+ surface.lastEffectTime = 0;
1132
+ }
1133
+ #encodeEffectStack(surface, view, encoder) {
1134
+ const stack = this.#shaderStack(view);
1135
+ this.#ensureEffectPasses(surface, stack);
1136
+ const now = view.effects?.animate ? performance.now() / 1_000 : 0;
1137
+ const delta = surface.lastEffectTime > 0 ? Math.min(0.1, Math.max(0, now - surface.lastEffectTime)) : 0;
1138
+ surface.lastEffectTime = now;
1139
+ const passCount = surface.effectPasses.length;
1140
+ for (let index = 0; index < passCount; index += 1) {
1141
+ const effectPass = surface.effectPasses[index];
1142
+ const storage = new ArrayBuffer(48);
1143
+ const values = new DataView(storage);
1144
+ values.setUint32(0, effectPass.mode, true);
1145
+ values.setUint32(4, surface.effectFrame, true);
1146
+ values.setUint32(8, index, true);
1147
+ values.setUint32(12, passCount, true);
1148
+ values.setFloat32(16, surface.canvas.width, true);
1149
+ values.setFloat32(20, surface.canvas.height, true);
1150
+ values.setFloat32(24, now, true);
1151
+ values.setFloat32(28, delta, true);
1152
+ values.setFloat32(32, (ORIGIN_X + view.cursor.x * CELL_WIDTH) * surface.dpr, true);
1153
+ values.setFloat32(36, (ORIGIN_Y + view.cursor.y * LINE_HEIGHT) * surface.dpr, true);
1154
+ values.setFloat32(40, view.cursor.visible ? 1 : 0, true);
1155
+ values.setFloat32(44, view.cursor.style, true);
1156
+ this.device.queue.writeBuffer(effectPass.configBuffer, 0, storage);
1157
+ const last = index === passCount - 1;
1158
+ const output = last
1159
+ ? surface.context.getCurrentTexture().createView()
1160
+ : surface.effectTextures[index % 2].createView();
1161
+ const pass = encoder.beginRenderPass({
1162
+ label: `Ghosttea shader effect pass ${index + 1}/${passCount}`,
1163
+ colorAttachments: [
1164
+ {
1165
+ view: output,
1166
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1167
+ loadOp: "clear",
1168
+ storeOp: "store",
1169
+ },
1170
+ ],
1171
+ });
1172
+ pass.setPipeline(this.#effectPipeline);
1173
+ pass.setBindGroup(0, effectPass.bindGroup);
1174
+ pass.draw(3);
1175
+ pass.end();
1176
+ }
1177
+ surface.effectFrame += 1;
1178
+ return passCount;
1179
+ }
1180
+ #encodeEffectsOnly(surface, view, encoder) {
1181
+ const effectPassCount = this.#encodeEffectStack(surface, view, encoder);
1182
+ if (!this.#performanceMeasurementEnabled)
1183
+ return undefined;
1184
+ return {
1185
+ ...emptyRenderMetrics(),
1186
+ canvasPixels: surface.canvas.width * surface.canvas.height,
1187
+ renderPasses: effectPassCount,
1188
+ drawCalls: effectPassCount,
1189
+ };
1190
+ }
1068
1191
  render(id, view) {
1069
1192
  const surface = this.#surfaces.get(id);
1070
1193
  if (!surface)
1071
1194
  return;
1072
- const postProcessEnabled = view.effects?.postProcess === "better-crt";
1073
- if (surface.postProcessEnabled !== postProcessEnabled) {
1074
- surface.postProcessEnabled = postProcessEnabled;
1075
- this.device.queue.writeBuffer(surface.postProcessConfigBuffer, 0, new Uint32Array([Number(postProcessEnabled), 0, 0, 0]));
1076
- }
1077
1195
  const encoder = this.device.createCommandEncoder({ label: `terminal frame ${id}` });
1078
1196
  const metrics = view.damage?.geometryChanged === false
1079
1197
  ? this.#encodeCachedRender(id, view, encoder)
@@ -1090,12 +1208,6 @@ export class WebGpuTerminalRenderer {
1090
1208
  const encoder = this.device.createCommandEncoder({ label: `terminal frame batch (${active.length} panes)` });
1091
1209
  const byId = new Map();
1092
1210
  for (const { id, view } of active) {
1093
- const surface = this.#surfaces.get(id);
1094
- const postProcessEnabled = view.effects?.postProcess === "better-crt";
1095
- if (surface.postProcessEnabled !== postProcessEnabled) {
1096
- surface.postProcessEnabled = postProcessEnabled;
1097
- this.device.queue.writeBuffer(surface.postProcessConfigBuffer, 0, new Uint32Array([Number(postProcessEnabled), 0, 0, 0]));
1098
- }
1099
1211
  byId.set(id, view.damage?.geometryChanged === false
1100
1212
  ? this.#encodeCachedRender(id, view, encoder)
1101
1213
  : this.#encodeRender(id, view, encoder));
@@ -1138,12 +1250,10 @@ export class WebGpuTerminalRenderer {
1138
1250
  const bottom = row === rowCount - 1
1139
1251
  ? viewportHeight
1140
1252
  : Math.min(viewportHeight, (ORIGIN_Y + (row + 1) * LINE_HEIGHT) * scale);
1141
- pushRectangle(rectangleVertices, 0, top, viewportWidth, Math.max(0, bottom - top),
1142
- // The rectangle pipeline blends. Force the row reset opaque so it
1143
- // replaces stale scene pixels even when a theme carries alpha.
1144
- [view.theme.background[0], view.theme.background[1], view.theme.background[2], 1], viewportWidth, viewportHeight);
1253
+ pushRectangle(rectangleVertices, 0, top, viewportWidth, Math.max(0, bottom - top), view.theme.background, viewportWidth, viewportHeight);
1145
1254
  }
1146
1255
  }
1256
+ const rowResetInstanceCount = rectangleVertices.length / 10;
1147
1257
  for (const row of renderRows) {
1148
1258
  for (const run of view.nativeStyleRows[row] ?? []) {
1149
1259
  const style = styleFor(run.styleId);
@@ -1164,6 +1274,7 @@ export class WebGpuTerminalRenderer {
1164
1274
  pushRectangle(rectangleVertices, (ORIGIN_X + first * CELL_WIDTH) * scale, (ORIGIN_Y + row * LINE_HEIGHT) * scale, Math.max(1, last - first + 1) * CELL_WIDTH * scale, LINE_HEIGHT * scale, view.theme.selection, viewportWidth, viewportHeight);
1165
1275
  }
1166
1276
  }
1277
+ pushBlockCursorBackground(rectangleVertices, view, renderRows, scale, viewportWidth, viewportHeight);
1167
1278
  const selectionInstanceCount = rectangleVertices.length / 10 - backgroundInstanceCount;
1168
1279
  if (!hasNativeRows) {
1169
1280
  this.#fallbackAtlas.prepare(renderRows.flatMap((row) => splitGraphemes(view.rows[row] ?? "")), scale);
@@ -1179,7 +1290,11 @@ export class WebGpuTerminalRenderer {
1179
1290
  if (style.invisible)
1180
1291
  continue;
1181
1292
  const isSelected = selectionContains(selection, row, instance.cellStart);
1182
- const foreground = isSelected ? view.theme.selectionForeground : style.foreground;
1293
+ const foreground = blockCursorContains(view, row, instance.cellStart, instance.cellSpan)
1294
+ ? (view.theme.cursorText ?? view.theme.background)
1295
+ : isSelected
1296
+ ? view.theme.selectionForeground
1297
+ : style.foreground;
1183
1298
  const boxDrawing = boxCells.get(instance.cellStart);
1184
1299
  if (boxDrawing) {
1185
1300
  let backdrop = style.background ?? view.theme.background;
@@ -1211,9 +1326,11 @@ export class WebGpuTerminalRenderer {
1211
1326
  const glyph = this.#fallbackAtlas.glyph(grapheme, scale);
1212
1327
  const cells = glyph?.cells ?? graphemeCellWidth(grapheme);
1213
1328
  if (glyph) {
1214
- const foreground = selectionContains(selection, row, column)
1215
- ? view.theme.selectionForeground
1216
- : view.theme.foreground;
1329
+ const foreground = blockCursorContains(view, row, column, cells)
1330
+ ? (view.theme.cursorText ?? view.theme.background)
1331
+ : selectionContains(selection, row, column)
1332
+ ? view.theme.selectionForeground
1333
+ : view.theme.foreground;
1217
1334
  pushGlyph(fallbackGlyphVertices, (ORIGIN_X + column * CELL_WIDTH) * scale, (ORIGIN_Y + row * LINE_HEIGHT) * scale, CELL_WIDTH * cells * scale, LINE_HEIGHT * scale, glyph, foreground, viewportWidth, viewportHeight);
1218
1335
  }
1219
1336
  column += cells;
@@ -1244,6 +1361,7 @@ export class WebGpuTerminalRenderer {
1244
1361
  glyphData: new Float32Array(glyphVertices),
1245
1362
  colorGlyphData: new Float32Array(colorGlyphVertices),
1246
1363
  fallbackGlyphData: new Float32Array(fallbackGlyphVertices),
1364
+ rowResetInstanceCount,
1247
1365
  backgroundInstanceCount,
1248
1366
  selectionInstanceCount,
1249
1367
  decorationInstanceStart,
@@ -1298,12 +1416,10 @@ export class WebGpuTerminalRenderer {
1298
1416
  const bottom = row === rowCount - 1
1299
1417
  ? viewportHeight
1300
1418
  : Math.min(viewportHeight, (ORIGIN_Y + (row + 1) * LINE_HEIGHT) * scale);
1301
- pushRectangle(rectangleVertices, 0, top, viewportWidth, Math.max(0, bottom - top),
1302
- // The rectangle pipeline blends. Force the row reset opaque so it
1303
- // replaces stale scene pixels even when a theme carries alpha.
1304
- [view.theme.background[0], view.theme.background[1], view.theme.background[2], 1], viewportWidth, viewportHeight);
1419
+ pushRectangle(rectangleVertices, 0, top, viewportWidth, Math.max(0, bottom - top), view.theme.background, viewportWidth, viewportHeight);
1305
1420
  }
1306
1421
  }
1422
+ const rowResetInstanceCount = rectangleVertices.length / 10;
1307
1423
  for (const row of renderRows) {
1308
1424
  for (const run of view.nativeStyleRows[row] ?? []) {
1309
1425
  const style = styleFor(run.styleId);
@@ -1324,6 +1440,7 @@ export class WebGpuTerminalRenderer {
1324
1440
  pushRectangle(rectangleVertices, (ORIGIN_X + first * CELL_WIDTH) * scale, (ORIGIN_Y + row * LINE_HEIGHT) * scale, Math.max(1, last - first + 1) * CELL_WIDTH * scale, LINE_HEIGHT * scale, view.theme.selection, viewportWidth, viewportHeight);
1325
1441
  }
1326
1442
  }
1443
+ pushBlockCursorBackground(rectangleVertices, view, renderRows, scale, viewportWidth, viewportHeight);
1327
1444
  const selectionInstanceCount = rectangleVertices.length / 10 - backgroundInstanceCount;
1328
1445
  const hasNativeRows = view.nativeRows.some((row) => row.length > 0);
1329
1446
  if (!hasNativeRows) {
@@ -1340,7 +1457,11 @@ export class WebGpuTerminalRenderer {
1340
1457
  if (style.invisible)
1341
1458
  continue;
1342
1459
  const isSelected = selectionContains(selection, row, instance.cellStart);
1343
- const foreground = isSelected ? view.theme.selectionForeground : style.foreground;
1460
+ const foreground = blockCursorContains(view, row, instance.cellStart, instance.cellSpan)
1461
+ ? (view.theme.cursorText ?? view.theme.background)
1462
+ : isSelected
1463
+ ? view.theme.selectionForeground
1464
+ : style.foreground;
1344
1465
  const boxDrawing = boxCells.get(instance.cellStart);
1345
1466
  if (boxDrawing) {
1346
1467
  let backdrop = style.background ?? view.theme.background;
@@ -1375,9 +1496,11 @@ export class WebGpuTerminalRenderer {
1375
1496
  const glyph = this.#fallbackAtlas.glyph(grapheme, scale);
1376
1497
  const cells = glyph?.cells ?? graphemeCellWidth(grapheme);
1377
1498
  if (glyph) {
1378
- const foreground = selectionContains(selection, row, column)
1379
- ? view.theme.selectionForeground
1380
- : view.theme.foreground;
1499
+ const foreground = blockCursorContains(view, row, column, cells)
1500
+ ? (view.theme.cursorText ?? view.theme.background)
1501
+ : selectionContains(selection, row, column)
1502
+ ? view.theme.selectionForeground
1503
+ : view.theme.foreground;
1381
1504
  pushGlyph(fallbackGlyphVertices, (ORIGIN_X + column * CELL_WIDTH) * scale, (ORIGIN_Y + row * LINE_HEIGHT) * scale, CELL_WIDTH * cells * scale, LINE_HEIGHT * scale, glyph, foreground, viewportWidth, viewportHeight);
1382
1505
  }
1383
1506
  column += cells;
@@ -1426,7 +1549,7 @@ export class WebGpuTerminalRenderer {
1426
1549
  pushRectangle(rectangleVertices, x, y + stroke, stroke, Math.max(0, height - stroke * 2), cursorColor, viewportWidth, viewportHeight);
1427
1550
  pushRectangle(rectangleVertices, x + width - stroke, y + stroke, stroke, Math.max(0, height - stroke * 2), cursorColor, viewportWidth, viewportHeight);
1428
1551
  }
1429
- else {
1552
+ else if (cursorStyle !== CursorStyle.Block) {
1430
1553
  pushRectangle(rectangleVertices, x, y, width, height, cursorColor, viewportWidth, viewportHeight);
1431
1554
  }
1432
1555
  }
@@ -1445,21 +1568,21 @@ export class WebGpuTerminalRenderer {
1445
1568
  colorAttachments: [
1446
1569
  {
1447
1570
  view: surface.sceneTexture.createView(),
1448
- clearValue: {
1449
- r: view.theme.background[0],
1450
- g: view.theme.background[1],
1451
- b: view.theme.background[2],
1452
- a: view.theme.background[3],
1453
- },
1571
+ clearValue: premultipliedClear(view.theme.background),
1454
1572
  loadOp: fullRedraw ? "clear" : "load",
1455
1573
  storeOp: "store",
1456
1574
  },
1457
1575
  ],
1458
1576
  });
1459
- if (rectangleBuffer && backgroundInstanceCount > 0) {
1577
+ if (rectangleBuffer && rowResetInstanceCount > 0) {
1578
+ pass.setPipeline(this.#rectangleOverwritePipeline);
1579
+ pass.setVertexBuffer(0, rectangleBuffer);
1580
+ pass.draw(6, rowResetInstanceCount);
1581
+ }
1582
+ if (rectangleBuffer && backgroundInstanceCount > rowResetInstanceCount) {
1460
1583
  pass.setPipeline(this.#rectanglePipeline);
1461
1584
  pass.setVertexBuffer(0, rectangleBuffer);
1462
- pass.draw(6, backgroundInstanceCount);
1585
+ pass.draw(6, backgroundInstanceCount - rowResetInstanceCount, 0, rowResetInstanceCount);
1463
1586
  }
1464
1587
  if (rectangleBuffer && selectionInstanceCount > 0) {
1465
1588
  pass.setPipeline(this.#rectanglePipeline);
@@ -1495,26 +1618,7 @@ export class WebGpuTerminalRenderer {
1495
1618
  pass.draw(6, cursorInstanceCount, 0, cursorInstanceStart);
1496
1619
  }
1497
1620
  pass.end();
1498
- const postProcessPass = encoder.beginRenderPass({
1499
- label: `Ghostty custom shader pass ${id}`,
1500
- colorAttachments: [
1501
- {
1502
- view: surface.context.getCurrentTexture().createView(),
1503
- clearValue: {
1504
- r: view.theme.background[0],
1505
- g: view.theme.background[1],
1506
- b: view.theme.background[2],
1507
- a: 1,
1508
- },
1509
- loadOp: "clear",
1510
- storeOp: "store",
1511
- },
1512
- ],
1513
- });
1514
- postProcessPass.setPipeline(this.#postProcessPipeline);
1515
- postProcessPass.setBindGroup(0, surface.postProcessBindGroup);
1516
- postProcessPass.draw(3);
1517
- postProcessPass.end();
1621
+ const effectPassCount = this.#encodeEffectStack(surface, view, encoder);
1518
1622
  surface.sceneValid = true;
1519
1623
  if (!this.#performanceMeasurementEnabled)
1520
1624
  return undefined;
@@ -1529,15 +1633,16 @@ export class WebGpuTerminalRenderer {
1529
1633
  geometryCacheHits: 0,
1530
1634
  geometryCacheMisses: 0,
1531
1635
  canvasPixels: viewportWidth * viewportHeight,
1532
- renderPasses: 2,
1533
- drawCalls: Number(backgroundInstanceCount > 0) +
1636
+ renderPasses: 1 + effectPassCount,
1637
+ drawCalls: Number(rowResetInstanceCount > 0) +
1638
+ Number(backgroundInstanceCount > rowResetInstanceCount) +
1534
1639
  Number(selectionInstanceCount > 0) +
1535
1640
  Number(glyphData.length > 0) +
1536
1641
  Number(colorGlyphData.length > 0) +
1537
1642
  Number(fallbackGlyphData.length > 0) +
1538
1643
  Number(decorationInstanceCount > 0) +
1539
1644
  Number(cursorInstanceCount > 0) +
1540
- 1,
1645
+ effectPassCount,
1541
1646
  rectangleVertices: (rectangleVertices.length / 10) * 6,
1542
1647
  monoGlyphVertices: (glyphVertices.length / 12) * 6,
1543
1648
  colorGlyphVertices: (colorGlyphVertices.length / 12) * 6,
@@ -1557,10 +1662,13 @@ export class WebGpuTerminalRenderer {
1557
1662
  const surface = this.#surfaces.get(id);
1558
1663
  if (!surface)
1559
1664
  return;
1560
- if (!surface.sceneValid || !view.damage || view.damage.full)
1665
+ if (!surface.sceneValid || !view.damage || view.damage.full) {
1561
1666
  return this.#encodeRender(id, view, encoder);
1667
+ }
1562
1668
  const rowCount = Math.max(view.rows.length, view.nativeRows.length, view.nativeStyleRows.length);
1563
1669
  const damage = rowsForDamage(rowCount, view.damage, surface.sceneValid);
1670
+ if (damage.rows.length === 0)
1671
+ return this.#encodeEffectsOnly(surface, view, encoder);
1564
1672
  const monoUploadsBefore = this.#performanceMeasurementEnabled ? this.#monoAtlas.uploadMetrics() : undefined;
1565
1673
  const colorUploadsBefore = this.#performanceMeasurementEnabled ? this.#colorAtlas.uploadMetrics() : undefined;
1566
1674
  const fallbackUploadsBefore = this.#performanceMeasurementEnabled ? this.#fallbackAtlas.uploadMetrics() : undefined;
@@ -1603,6 +1711,7 @@ export class WebGpuTerminalRenderer {
1603
1711
  fallbackGlyphBuffer: promote
1604
1712
  ? createCachedVertexBuffer(this.device, `cached fallback glyphs ${id}`, cpu.fallbackGlyphData)
1605
1713
  : surface.fallbackGlyphBuffer.write(cpu.fallbackGlyphData),
1714
+ rowResetInstanceCount: cpu.rowResetInstanceCount,
1606
1715
  backgroundInstanceCount: cpu.backgroundInstanceCount,
1607
1716
  selectionInstanceCount: cpu.selectionInstanceCount,
1608
1717
  decorationInstanceStart: cpu.decorationInstanceStart,
@@ -1652,21 +1761,21 @@ export class WebGpuTerminalRenderer {
1652
1761
  colorAttachments: [
1653
1762
  {
1654
1763
  view: surface.sceneTexture.createView(),
1655
- clearValue: {
1656
- r: view.theme.background[0],
1657
- g: view.theme.background[1],
1658
- b: view.theme.background[2],
1659
- a: view.theme.background[3],
1660
- },
1764
+ clearValue: premultipliedClear(view.theme.background),
1661
1765
  loadOp: "load",
1662
1766
  storeOp: "store",
1663
1767
  },
1664
1768
  ],
1665
1769
  });
1666
- if (geometry.rectangleBuffer && geometry.backgroundInstanceCount > 0) {
1770
+ if (geometry.rectangleBuffer && geometry.rowResetInstanceCount > 0) {
1771
+ pass.setPipeline(this.#rectangleOverwritePipeline);
1772
+ pass.setVertexBuffer(0, geometry.rectangleBuffer);
1773
+ pass.draw(6, geometry.rowResetInstanceCount);
1774
+ }
1775
+ if (geometry.rectangleBuffer && geometry.backgroundInstanceCount > geometry.rowResetInstanceCount) {
1667
1776
  pass.setPipeline(this.#rectanglePipeline);
1668
1777
  pass.setVertexBuffer(0, geometry.rectangleBuffer);
1669
- pass.draw(6, geometry.backgroundInstanceCount);
1778
+ pass.draw(6, geometry.backgroundInstanceCount - geometry.rowResetInstanceCount, 0, geometry.rowResetInstanceCount);
1670
1779
  }
1671
1780
  if (geometry.rectangleBuffer && geometry.selectionInstanceCount > 0) {
1672
1781
  pass.setPipeline(this.#rectanglePipeline);
@@ -1702,26 +1811,7 @@ export class WebGpuTerminalRenderer {
1702
1811
  pass.draw(6, cursorInstanceCount, 0, cursorInstanceStart);
1703
1812
  }
1704
1813
  pass.end();
1705
- const postProcessPass = encoder.beginRenderPass({
1706
- label: `Ghostty custom shader pass ${id}`,
1707
- colorAttachments: [
1708
- {
1709
- view: surface.context.getCurrentTexture().createView(),
1710
- clearValue: {
1711
- r: view.theme.background[0],
1712
- g: view.theme.background[1],
1713
- b: view.theme.background[2],
1714
- a: 1,
1715
- },
1716
- loadOp: "clear",
1717
- storeOp: "store",
1718
- },
1719
- ],
1720
- });
1721
- postProcessPass.setPipeline(this.#postProcessPipeline);
1722
- postProcessPass.setBindGroup(0, surface.postProcessBindGroup);
1723
- postProcessPass.draw(3);
1724
- postProcessPass.end();
1814
+ const effectPassCount = this.#encodeEffectStack(surface, view, encoder);
1725
1815
  surface.sceneValid = true;
1726
1816
  if (!this.#performanceMeasurementEnabled)
1727
1817
  return undefined;
@@ -1736,15 +1826,16 @@ export class WebGpuTerminalRenderer {
1736
1826
  geometryCacheHits: Number(cacheHit),
1737
1827
  geometryCacheMisses: Number(!cacheHit),
1738
1828
  canvasPixels: viewportWidth * viewportHeight,
1739
- renderPasses: 2,
1740
- drawCalls: Number(geometry.backgroundInstanceCount > 0) +
1829
+ renderPasses: 1 + effectPassCount,
1830
+ drawCalls: Number(geometry.rowResetInstanceCount > 0) +
1831
+ Number(geometry.backgroundInstanceCount > geometry.rowResetInstanceCount) +
1741
1832
  Number(geometry.selectionInstanceCount > 0) +
1742
1833
  Number(geometry.glyphInstanceCount > 0) +
1743
1834
  Number(geometry.colorGlyphInstanceCount > 0) +
1744
1835
  Number(geometry.fallbackGlyphInstanceCount > 0) +
1745
1836
  Number(geometry.decorationInstanceCount > 0) +
1746
1837
  Number(cursorInstanceCount > 0) +
1747
- 1,
1838
+ effectPassCount,
1748
1839
  rectangleVertices: (geometry.rectangleInstanceCount + cursorInstanceCount) * 6,
1749
1840
  monoGlyphVertices: geometry.glyphInstanceCount * 6,
1750
1841
  colorGlyphVertices: geometry.colorGlyphInstanceCount * 6,