@hex1b/web-terminal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2 +0 -0
- package/dist/fonts/cascadia-mono-nf/LICENSE.txt +94 -0
- package/dist/fonts/cascadia-mono-nf/README.md +29 -0
- package/dist/history-state.d.ts +27 -0
- package/dist/history-state.d.ts.map +1 -0
- package/dist/history-state.js +194 -0
- package/dist/history-state.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/input-policy.d.ts +42 -0
- package/dist/input-policy.d.ts.map +1 -0
- package/dist/input-policy.js +143 -0
- package/dist/input-policy.js.map +1 -0
- package/dist/mouse-input.d.ts +26 -0
- package/dist/mouse-input.d.ts.map +1 -0
- package/dist/mouse-input.js +356 -0
- package/dist/mouse-input.js.map +1 -0
- package/dist/protocol.d.ts +17 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +270 -0
- package/dist/protocol.js.map +1 -0
- package/dist/renderer.d.ts +113 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +595 -0
- package/dist/renderer.js.map +1 -0
- package/dist/selection-input.d.ts +36 -0
- package/dist/selection-input.d.ts.map +1 -0
- package/dist/selection-input.js +69 -0
- package/dist/selection-input.js.map +1 -0
- package/dist/selection-ui.d.ts +24 -0
- package/dist/selection-ui.d.ts.map +1 -0
- package/dist/selection-ui.js +113 -0
- package/dist/selection-ui.js.map +1 -0
- package/dist/terminal-font.d.ts +23 -0
- package/dist/terminal-font.d.ts.map +1 -0
- package/dist/terminal-font.js +85 -0
- package/dist/terminal-font.js.map +1 -0
- package/dist/terminal-sizing.d.ts +8 -0
- package/dist/terminal-sizing.d.ts.map +1 -0
- package/dist/terminal-sizing.js +38 -0
- package/dist/terminal-sizing.js.map +1 -0
- package/dist/terminal-theme.d.ts +2 -0
- package/dist/terminal-theme.d.ts.map +1 -0
- package/dist/terminal-theme.js +39 -0
- package/dist/terminal-theme.js.map +1 -0
- package/dist/terminal-worker.d.ts +2 -0
- package/dist/terminal-worker.d.ts.map +1 -0
- package/dist/terminal-worker.js +285 -0
- package/dist/terminal-worker.js.map +1 -0
- package/dist/types.d.ts +312 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +3 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +7 -0
- package/dist/validation.js.map +1 -0
- package/dist/web-terminal.d.ts +51 -0
- package/dist/web-terminal.d.ts.map +1 -0
- package/dist/web-terminal.js +728 -0
- package/dist/web-terminal.js.map +1 -0
- package/dist/wire-types.d.ts +218 -0
- package/dist/wire-types.d.ts.map +1 -0
- package/dist/wire-types.js +2 -0
- package/dist/wire-types.js.map +1 -0
- package/package.json +42 -0
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { LIMITS } from "./protocol.js";
|
|
2
|
+
import { loadFont, measureFont, normalizeFont } from "./terminal-font.js";
|
|
3
|
+
const CELL_WIDTH = 10;
|
|
4
|
+
const CELL_HEIGHT = 20;
|
|
5
|
+
const MAX_QUADS = 1024 * 1024;
|
|
6
|
+
const MAX_GLYPHS = 16384;
|
|
7
|
+
const MAX_GLYPH_KEY_UNITS = 1024 * 1024;
|
|
8
|
+
const STRIDE = 16;
|
|
9
|
+
const WHITE = [1, 1, 1, 1];
|
|
10
|
+
const shader = /* wgsl */ `
|
|
11
|
+
struct Viewport { size: vec2f, padding: vec2f }
|
|
12
|
+
@group(0) @binding(0) var<uniform> viewport: Viewport;
|
|
13
|
+
@group(0) @binding(1) var image: texture_2d<f32>;
|
|
14
|
+
@group(0) @binding(2) var imageSampler: sampler;
|
|
15
|
+
|
|
16
|
+
struct VertexOut {
|
|
17
|
+
@builtin(position) position: vec4f,
|
|
18
|
+
@location(0) uv: vec2f,
|
|
19
|
+
@location(1) color: vec4f,
|
|
20
|
+
@location(2) @interpolate(flat) mode: f32,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@vertex fn vertex(
|
|
24
|
+
@builtin(vertex_index) index: u32,
|
|
25
|
+
@location(0) rect: vec4f,
|
|
26
|
+
@location(1) uvRect: vec4f,
|
|
27
|
+
@location(2) color: vec4f,
|
|
28
|
+
@location(3) mode: f32,
|
|
29
|
+
) -> VertexOut {
|
|
30
|
+
let corners = array<vec2f, 6>(
|
|
31
|
+
vec2f(0, 0), vec2f(1, 0), vec2f(0, 1),
|
|
32
|
+
vec2f(0, 1), vec2f(1, 0), vec2f(1, 1)
|
|
33
|
+
);
|
|
34
|
+
let corner = corners[index];
|
|
35
|
+
let position = rect.xy + corner * rect.zw;
|
|
36
|
+
var out: VertexOut;
|
|
37
|
+
out.position = vec4f(position / viewport.size * vec2f(2, -2) + vec2f(-1, 1), 0, 1);
|
|
38
|
+
out.uv = mix(uvRect.xy, uvRect.zw, corner);
|
|
39
|
+
out.color = color;
|
|
40
|
+
out.mode = mode;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@fragment fn fragment(in: VertexOut) -> @location(0) vec4f {
|
|
45
|
+
// Explicit LOD avoids derivative-uniformity requirements across solid/mask/image batches.
|
|
46
|
+
let texel = textureSampleLevel(image, imageSampler, in.uv, 0);
|
|
47
|
+
if (in.mode < 0.5) { return in.color; }
|
|
48
|
+
if (in.mode < 1.5) { return vec4f(in.color.rgb, in.color.a * texel.a); }
|
|
49
|
+
return texel * in.color;
|
|
50
|
+
}`;
|
|
51
|
+
function rgba(packed) {
|
|
52
|
+
return [
|
|
53
|
+
(packed & 255) / 255,
|
|
54
|
+
((packed >>> 8) & 255) / 255,
|
|
55
|
+
((packed >>> 16) & 255) / 255,
|
|
56
|
+
((packed >>> 24) & 255) / 255,
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
function glyphKey(cell) {
|
|
60
|
+
return `${cell.attributes & 5}/${cell.width}/${cell.text}`;
|
|
61
|
+
}
|
|
62
|
+
/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */
|
|
63
|
+
function packGlyphs(glyphs, size, scale, initial = { x: 0, y: 0, rowHeight: 0 }) {
|
|
64
|
+
let { x, y, rowHeight } = initial;
|
|
65
|
+
const placements = [];
|
|
66
|
+
for (const [key, cell] of glyphs) {
|
|
67
|
+
const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;
|
|
68
|
+
const height = Math.ceil(CELL_HEIGHT * scale) + 4;
|
|
69
|
+
if (width > size || height > size)
|
|
70
|
+
return null;
|
|
71
|
+
if (x + width > size) {
|
|
72
|
+
x = 0;
|
|
73
|
+
y += rowHeight;
|
|
74
|
+
rowHeight = 0;
|
|
75
|
+
}
|
|
76
|
+
if (y + height > size)
|
|
77
|
+
return null;
|
|
78
|
+
placements.push({ key, cell, x, y, width, height });
|
|
79
|
+
x += width;
|
|
80
|
+
rowHeight = Math.max(rowHeight, height);
|
|
81
|
+
}
|
|
82
|
+
return { placements, shelf: { x, y, rowHeight } };
|
|
83
|
+
}
|
|
84
|
+
/** WebGPU instanced quads; Canvas2D is used only to rasterize reusable glyphs. */
|
|
85
|
+
export class TerminalRenderer {
|
|
86
|
+
canvas;
|
|
87
|
+
scale;
|
|
88
|
+
backingScale;
|
|
89
|
+
device;
|
|
90
|
+
fontConfiguration;
|
|
91
|
+
fontMetrics;
|
|
92
|
+
context;
|
|
93
|
+
images;
|
|
94
|
+
glyphs;
|
|
95
|
+
imageUploadBytes;
|
|
96
|
+
imagePayloadBytes;
|
|
97
|
+
glyphUploadBytes;
|
|
98
|
+
atlasRebuilds;
|
|
99
|
+
textureBytes;
|
|
100
|
+
instances;
|
|
101
|
+
instanceBuffer;
|
|
102
|
+
instanceBufferBytes;
|
|
103
|
+
disposed;
|
|
104
|
+
columns;
|
|
105
|
+
rows;
|
|
106
|
+
// Initialized by create() before the renderer can prepare or submit frames.
|
|
107
|
+
font;
|
|
108
|
+
format;
|
|
109
|
+
uniform;
|
|
110
|
+
sampler;
|
|
111
|
+
pipeline;
|
|
112
|
+
rasterCanvas;
|
|
113
|
+
raster;
|
|
114
|
+
atlas;
|
|
115
|
+
glyphKeyUnits = 0;
|
|
116
|
+
shelf = { x: 0, y: 0, rowHeight: 0 };
|
|
117
|
+
canvasLimited = false;
|
|
118
|
+
width = 0;
|
|
119
|
+
height = 0;
|
|
120
|
+
quadCount = 0;
|
|
121
|
+
batches = [];
|
|
122
|
+
static async create(canvas, scale, onFatal, font) {
|
|
123
|
+
const normalizedFont = normalizeFont(font);
|
|
124
|
+
if (!self.isSecureContext)
|
|
125
|
+
throw new Error("WebGPU requires HTTPS or localhost");
|
|
126
|
+
if (!navigator.gpu)
|
|
127
|
+
throw new Error("WebGPU is unavailable in this browser worker");
|
|
128
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
129
|
+
if (!adapter)
|
|
130
|
+
throw new Error("No WebGPU adapter is available; check browser GPU support");
|
|
131
|
+
const device = await adapter.requestDevice();
|
|
132
|
+
const renderer = new TerminalRenderer(canvas, scale, device, normalizedFont);
|
|
133
|
+
device.lost.then(info => {
|
|
134
|
+
if (!renderer.disposed)
|
|
135
|
+
onFatal(new Error(`WebGPU device lost: ${info.message || info.reason}`));
|
|
136
|
+
});
|
|
137
|
+
device.addEventListener("uncapturederror", event => onFatal(event.error));
|
|
138
|
+
try {
|
|
139
|
+
await renderer.initialize();
|
|
140
|
+
return renderer;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
renderer.dispose();
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
constructor(canvas, scale, device, font) {
|
|
148
|
+
if (!Number.isFinite(scale) || scale < 0.5 || scale > 3)
|
|
149
|
+
throw new Error("Invalid backing scale");
|
|
150
|
+
this.canvas = canvas;
|
|
151
|
+
this.scale = scale;
|
|
152
|
+
this.backingScale = scale;
|
|
153
|
+
this.device = device;
|
|
154
|
+
this.fontConfiguration = font;
|
|
155
|
+
this.fontMetrics = new Map();
|
|
156
|
+
const context = canvas.getContext("webgpu");
|
|
157
|
+
if (!context)
|
|
158
|
+
throw new Error("Could not create an OffscreenCanvas WebGPU context");
|
|
159
|
+
this.context = context;
|
|
160
|
+
this.images = new Map();
|
|
161
|
+
this.glyphs = new Map();
|
|
162
|
+
this.imageUploadBytes = 0;
|
|
163
|
+
this.imagePayloadBytes = 0;
|
|
164
|
+
this.glyphUploadBytes = 0;
|
|
165
|
+
this.atlasRebuilds = 0;
|
|
166
|
+
this.textureBytes = 0;
|
|
167
|
+
this.instances = new Float32Array(4096 * STRIDE);
|
|
168
|
+
this.instanceBuffer = null;
|
|
169
|
+
this.instanceBufferBytes = 0;
|
|
170
|
+
this.disposed = false;
|
|
171
|
+
this.columns = 0;
|
|
172
|
+
this.rows = 0;
|
|
173
|
+
}
|
|
174
|
+
async initialize() {
|
|
175
|
+
this.font = await loadFont(this.fontConfiguration);
|
|
176
|
+
const device = this.device;
|
|
177
|
+
this.format = navigator.gpu.getPreferredCanvasFormat();
|
|
178
|
+
this.context.configure({ device, format: this.format, alphaMode: "opaque" });
|
|
179
|
+
this.uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
180
|
+
this.sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" });
|
|
181
|
+
const module = device.createShaderModule({ code: shader });
|
|
182
|
+
this.pipeline = await device.createRenderPipelineAsync({
|
|
183
|
+
layout: "auto",
|
|
184
|
+
vertex: {
|
|
185
|
+
module,
|
|
186
|
+
entryPoint: "vertex",
|
|
187
|
+
buffers: [{
|
|
188
|
+
arrayStride: STRIDE * 4,
|
|
189
|
+
stepMode: "instance",
|
|
190
|
+
attributes: [
|
|
191
|
+
{ shaderLocation: 0, offset: 0, format: "float32x4" },
|
|
192
|
+
{ shaderLocation: 1, offset: 16, format: "float32x4" },
|
|
193
|
+
{ shaderLocation: 2, offset: 32, format: "float32x4" },
|
|
194
|
+
{ shaderLocation: 3, offset: 48, format: "float32" },
|
|
195
|
+
],
|
|
196
|
+
}],
|
|
197
|
+
},
|
|
198
|
+
fragment: {
|
|
199
|
+
module,
|
|
200
|
+
entryPoint: "fragment",
|
|
201
|
+
targets: [{
|
|
202
|
+
format: this.format,
|
|
203
|
+
blend: {
|
|
204
|
+
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha" },
|
|
205
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
|
|
206
|
+
},
|
|
207
|
+
}],
|
|
208
|
+
},
|
|
209
|
+
primitive: { topology: "triangle-list" },
|
|
210
|
+
});
|
|
211
|
+
this.rasterCanvas = new OffscreenCanvas(1, 1);
|
|
212
|
+
const raster = this.rasterCanvas.getContext("2d", { willReadFrequently: true });
|
|
213
|
+
if (!raster)
|
|
214
|
+
throw new Error("Worker glyph rasterization is unavailable");
|
|
215
|
+
this.raster = raster;
|
|
216
|
+
this.resetAtlas(Math.min(2048, device.limits.maxTextureDimension2D));
|
|
217
|
+
}
|
|
218
|
+
createTexture(width, height, label) {
|
|
219
|
+
if (width > this.device.limits.maxTextureDimension2D || height > this.device.limits.maxTextureDimension2D) {
|
|
220
|
+
throw new Error(`${label} exceeds the GPU texture dimension limit`);
|
|
221
|
+
}
|
|
222
|
+
const texture = this.device.createTexture({
|
|
223
|
+
label,
|
|
224
|
+
size: [width, height],
|
|
225
|
+
format: "rgba8unorm",
|
|
226
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
227
|
+
});
|
|
228
|
+
const bindGroup = this.device.createBindGroup({
|
|
229
|
+
layout: this.pipeline.getBindGroupLayout(0),
|
|
230
|
+
entries: [
|
|
231
|
+
{ binding: 0, resource: { buffer: this.uniform } },
|
|
232
|
+
{ binding: 1, resource: texture.createView() },
|
|
233
|
+
{ binding: 2, resource: this.sampler },
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
return { texture, bindGroup, width, height };
|
|
237
|
+
}
|
|
238
|
+
resetAtlas(size) {
|
|
239
|
+
this.atlas?.texture.destroy();
|
|
240
|
+
this.atlas = this.createTexture(size, size, "Glyph atlas");
|
|
241
|
+
this.glyphs.clear();
|
|
242
|
+
this.glyphKeyUnits = 0;
|
|
243
|
+
this.shelf = { x: 0, y: 0, rowHeight: 0 };
|
|
244
|
+
}
|
|
245
|
+
resize(columns, rows, viewport) {
|
|
246
|
+
const width = columns * CELL_WIDTH;
|
|
247
|
+
const height = rows * CELL_HEIGHT;
|
|
248
|
+
const limit = this.device.limits.maxTextureDimension2D;
|
|
249
|
+
const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);
|
|
250
|
+
this.canvasLimited = width * requested > limit || height * requested > limit;
|
|
251
|
+
this.backingScale = Math.min(requested, limit / width, limit / height);
|
|
252
|
+
const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));
|
|
253
|
+
const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));
|
|
254
|
+
if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight)
|
|
255
|
+
return;
|
|
256
|
+
this.columns = columns;
|
|
257
|
+
this.rows = rows;
|
|
258
|
+
this.width = width;
|
|
259
|
+
this.height = height;
|
|
260
|
+
this.canvas.width = backingWidth;
|
|
261
|
+
this.canvas.height = backingHeight;
|
|
262
|
+
this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([width, height, 0, 0]));
|
|
263
|
+
}
|
|
264
|
+
/** Call only between submissions. Missing/over-budget resources terminate the session. */
|
|
265
|
+
async updateImages(incoming, retainedKeys) {
|
|
266
|
+
const retained = new Set(retainedKeys);
|
|
267
|
+
const replacements = new Map(incoming.map(image => [image.key, image]));
|
|
268
|
+
let projectedBytes = 0;
|
|
269
|
+
for (const key of retained) {
|
|
270
|
+
const image = replacements.get(key) || this.images.get(key);
|
|
271
|
+
if (!image)
|
|
272
|
+
throw new Error(`Missing retained image resource: ${key}`);
|
|
273
|
+
projectedBytes += image.width * image.height * 4;
|
|
274
|
+
}
|
|
275
|
+
if (projectedBytes > LIMITS.textureBytes)
|
|
276
|
+
throw new Error("Retained images exceed the 256 MiB texture budget");
|
|
277
|
+
for (const [key, image] of this.images) {
|
|
278
|
+
if (!retained.has(key) || replacements.has(key)) {
|
|
279
|
+
image.texture.destroy();
|
|
280
|
+
this.textureBytes -= image.width * image.height * 4;
|
|
281
|
+
this.images.delete(key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const image of incoming) {
|
|
285
|
+
const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);
|
|
286
|
+
try {
|
|
287
|
+
if (image.format === "rgba") {
|
|
288
|
+
this.device.queue.writeTexture({ texture: resource.texture }, image.bytes, { bytesPerRow: image.width * 4, rowsPerImage: image.height }, [image.width, image.height]);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
// Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.
|
|
292
|
+
const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);
|
|
293
|
+
if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||
|
|
294
|
+
png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||
|
|
295
|
+
png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {
|
|
296
|
+
throw new Error(`PNG header dimensions do not match resource ${image.key}`);
|
|
297
|
+
}
|
|
298
|
+
const bitmap = await createImageBitmap(new Blob([image.bytes], { type: "image/png" }), {
|
|
299
|
+
premultiplyAlpha: "none",
|
|
300
|
+
colorSpaceConversion: "none",
|
|
301
|
+
});
|
|
302
|
+
try {
|
|
303
|
+
if (bitmap.width !== image.width || bitmap.height !== image.height)
|
|
304
|
+
throw new Error("Decoded PNG dimension mismatch");
|
|
305
|
+
this.device.queue.copyExternalImageToTexture({ source: bitmap }, { texture: resource.texture, premultipliedAlpha: false }, [image.width, image.height]);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
bitmap.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
this.images.set(image.key, resource);
|
|
312
|
+
this.textureBytes += image.width * image.height * 4;
|
|
313
|
+
this.imageUploadBytes += image.width * image.height * 4;
|
|
314
|
+
this.imagePayloadBytes += image.byteLength;
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
resource.texture.destroy();
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
prepareGlyphs(cells) {
|
|
323
|
+
const visible = new Map();
|
|
324
|
+
for (const cell of cells) {
|
|
325
|
+
if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64))
|
|
326
|
+
continue;
|
|
327
|
+
visible.set(glyphKey(cell), cell);
|
|
328
|
+
}
|
|
329
|
+
const keyUnits = (glyphs) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);
|
|
330
|
+
if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {
|
|
331
|
+
throw new Error("Visible glyph metadata exceeds the bounded glyph cache");
|
|
332
|
+
}
|
|
333
|
+
const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));
|
|
334
|
+
if (!missing.size)
|
|
335
|
+
return;
|
|
336
|
+
const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&
|
|
337
|
+
this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;
|
|
338
|
+
let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;
|
|
339
|
+
if (!plan) {
|
|
340
|
+
let size = this.atlas.width;
|
|
341
|
+
const maxSize = Math.min(4096, this.device.limits.maxTextureDimension2D);
|
|
342
|
+
while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {
|
|
343
|
+
size = Math.min(size * 2, maxSize);
|
|
344
|
+
}
|
|
345
|
+
if (!plan)
|
|
346
|
+
throw new Error("Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale");
|
|
347
|
+
this.resetAtlas(size);
|
|
348
|
+
this.atlasRebuilds++;
|
|
349
|
+
}
|
|
350
|
+
for (const placement of plan.placements)
|
|
351
|
+
this.uploadGlyph(placement);
|
|
352
|
+
this.shelf = plan.shelf;
|
|
353
|
+
}
|
|
354
|
+
uploadGlyph({ key, cell, x, y, width, height }) {
|
|
355
|
+
const scale = this.scale;
|
|
356
|
+
const raster = this.raster;
|
|
357
|
+
const style = cell.attributes & 5;
|
|
358
|
+
let metrics = this.fontMetrics.get(style);
|
|
359
|
+
if (!metrics) {
|
|
360
|
+
metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);
|
|
361
|
+
this.fontMetrics.set(style, metrics);
|
|
362
|
+
}
|
|
363
|
+
this.rasterCanvas.width = width;
|
|
364
|
+
this.rasterCanvas.height = height;
|
|
365
|
+
raster.font = metrics.font;
|
|
366
|
+
raster.textBaseline = "alphabetic";
|
|
367
|
+
raster.textAlign = "left";
|
|
368
|
+
raster.fillStyle = "white";
|
|
369
|
+
// One transform per font style, not per glyph: borders remain font outlines,
|
|
370
|
+
// and graphemes are clipped to their server-owned span without individual stretching.
|
|
371
|
+
raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);
|
|
372
|
+
raster.fillText(cell.text, 0, 0);
|
|
373
|
+
const pixels = raster.getImageData(0, 0, width, height);
|
|
374
|
+
let colored = false;
|
|
375
|
+
for (let i = 0; i < pixels.data.length; i += 4) {
|
|
376
|
+
if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {
|
|
377
|
+
colored = true;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
this.device.queue.writeTexture({ texture: this.atlas.texture, origin: [x, y] }, pixels.data, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]);
|
|
382
|
+
this.glyphUploadBytes += width * height * 4;
|
|
383
|
+
this.glyphs.set(key, {
|
|
384
|
+
colored,
|
|
385
|
+
u0: (x + 2) / this.atlas.width,
|
|
386
|
+
v0: (y + 2) / this.atlas.height,
|
|
387
|
+
u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,
|
|
388
|
+
v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,
|
|
389
|
+
});
|
|
390
|
+
this.glyphKeyUnits += key.length;
|
|
391
|
+
}
|
|
392
|
+
/** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */
|
|
393
|
+
quad(resource, x, y, width, height, color, mode = 0, uv = [0, 0, 1, 1], clip = [0, 0, this.width, this.height]) {
|
|
394
|
+
if (width <= 0 || height <= 0 || color[3] <= 0)
|
|
395
|
+
return;
|
|
396
|
+
const left = Math.max(0, x, clip[0]);
|
|
397
|
+
const top = Math.max(0, y, clip[1]);
|
|
398
|
+
const right = Math.min(this.width, x + width, clip[0] + clip[2]);
|
|
399
|
+
const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);
|
|
400
|
+
if (right <= left || bottom <= top)
|
|
401
|
+
return;
|
|
402
|
+
if (this.quadCount >= MAX_QUADS)
|
|
403
|
+
throw new Error("Frame exceeds bounded quad budget");
|
|
404
|
+
const offset = this.quadCount * STRIDE;
|
|
405
|
+
if (offset + STRIDE > this.instances.length) {
|
|
406
|
+
const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));
|
|
407
|
+
grown.set(this.instances);
|
|
408
|
+
this.instances = grown;
|
|
409
|
+
}
|
|
410
|
+
const du = uv[2] - uv[0];
|
|
411
|
+
const dv = uv[3] - uv[1];
|
|
412
|
+
this.instances.set([
|
|
413
|
+
left, top, right - left, bottom - top,
|
|
414
|
+
uv[0] + (left - x) / width * du,
|
|
415
|
+
uv[1] + (top - y) / height * dv,
|
|
416
|
+
uv[0] + (right - x) / width * du,
|
|
417
|
+
uv[1] + (bottom - y) / height * dv,
|
|
418
|
+
...color, mode, 0, 0, 0,
|
|
419
|
+
], offset);
|
|
420
|
+
const last = this.batches[this.batches.length - 1];
|
|
421
|
+
if (last?.resource === resource)
|
|
422
|
+
last.count++;
|
|
423
|
+
else
|
|
424
|
+
this.batches.push({ resource, start: this.quadCount, count: 1 });
|
|
425
|
+
this.quadCount++;
|
|
426
|
+
}
|
|
427
|
+
solid(x, y, width, height, color) {
|
|
428
|
+
this.quad(this.atlas, x, y, width, height, color);
|
|
429
|
+
}
|
|
430
|
+
placement(placement) {
|
|
431
|
+
const image = this.images.get(placement.key);
|
|
432
|
+
if (!image)
|
|
433
|
+
throw new Error(`Placement texture is missing: ${placement.key}`);
|
|
434
|
+
const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;
|
|
435
|
+
if (!sw || !sh || !placement.width || !placement.height)
|
|
436
|
+
return;
|
|
437
|
+
// Clip out-of-texture source regions in destination space instead of stretching edge texels.
|
|
438
|
+
const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;
|
|
439
|
+
const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;
|
|
440
|
+
const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;
|
|
441
|
+
const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;
|
|
442
|
+
const left = Math.max(sourceLeft, placement.clipX);
|
|
443
|
+
const top = Math.max(sourceTop, placement.clipY);
|
|
444
|
+
const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);
|
|
445
|
+
const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);
|
|
446
|
+
this.quad(image, placement.x, placement.y, placement.width, placement.height, WHITE, 2, [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height], [left, top, Math.max(0, right - left), Math.max(0, bottom - top)]);
|
|
447
|
+
}
|
|
448
|
+
decorations(cell, x, y, width, foreground) {
|
|
449
|
+
if (cell.attributes & 128)
|
|
450
|
+
this.solid(x, y + 10, width, 1, foreground);
|
|
451
|
+
if (cell.attributes & 256)
|
|
452
|
+
this.solid(x, y + 1, width, 1, foreground);
|
|
453
|
+
const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);
|
|
454
|
+
const color = rgba(cell.underlineColor);
|
|
455
|
+
if (style === 1)
|
|
456
|
+
this.solid(x, y + 18, width, 1, color);
|
|
457
|
+
else if (style === 2) {
|
|
458
|
+
this.solid(x, y + 16, width, 1, color);
|
|
459
|
+
this.solid(x, y + 18, width, 1, color);
|
|
460
|
+
}
|
|
461
|
+
else if (style === 3) {
|
|
462
|
+
for (let dx = 0; dx < width; dx++) {
|
|
463
|
+
this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else if (style === 4 || style === 5) {
|
|
467
|
+
const step = style === 4 ? 2 : 5;
|
|
468
|
+
const segment = style === 4 ? 1 : 3;
|
|
469
|
+
for (let dx = 0; dx < width; dx += step)
|
|
470
|
+
this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
render(cells, metadata, blinkOn) {
|
|
474
|
+
const start = performance.now();
|
|
475
|
+
this.quadCount = 0;
|
|
476
|
+
this.batches = [];
|
|
477
|
+
const placements = metadata.placements.map((placement, order) => ({
|
|
478
|
+
placement,
|
|
479
|
+
order,
|
|
480
|
+
z: placement.kind === "sixel" ? -1 : placement.z,
|
|
481
|
+
})).sort((a, b) => a.z - b.z || a.order - b.order);
|
|
482
|
+
for (const item of placements)
|
|
483
|
+
if (item.z < -1073741824)
|
|
484
|
+
this.placement(item.placement);
|
|
485
|
+
for (let i = 0; i < cells.length; i++) {
|
|
486
|
+
const cell = cells[i];
|
|
487
|
+
if (!cell)
|
|
488
|
+
continue;
|
|
489
|
+
this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));
|
|
490
|
+
}
|
|
491
|
+
for (const item of placements)
|
|
492
|
+
if (item.z >= -1073741824 && item.z < 0)
|
|
493
|
+
this.placement(item.placement);
|
|
494
|
+
for (let i = 0; i < cells.length; i++) {
|
|
495
|
+
const cell = cells[i];
|
|
496
|
+
if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn))
|
|
497
|
+
continue;
|
|
498
|
+
const x = (i % this.columns) * CELL_WIDTH;
|
|
499
|
+
const y = Math.floor(i / this.columns) * CELL_HEIGHT;
|
|
500
|
+
const width = Math.min(cell.width * CELL_WIDTH, this.width - x);
|
|
501
|
+
const foreground = rgba(cell.foreground);
|
|
502
|
+
const glyph = this.glyphs.get(glyphKey(cell));
|
|
503
|
+
if (glyph) {
|
|
504
|
+
const tint = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;
|
|
505
|
+
this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT, tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);
|
|
506
|
+
}
|
|
507
|
+
// Reverse and dim are already reflected in server-projected colors.
|
|
508
|
+
this.decorations(cell, x, y, width, foreground);
|
|
509
|
+
}
|
|
510
|
+
for (const item of placements)
|
|
511
|
+
if (item.z >= 0)
|
|
512
|
+
this.placement(item.placement);
|
|
513
|
+
const cursor = metadata.cursor;
|
|
514
|
+
const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;
|
|
515
|
+
if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {
|
|
516
|
+
const cell = cells[cursor.y * this.columns + cursor.x];
|
|
517
|
+
const color = rgba(cell?.foreground ?? 0xffffffff);
|
|
518
|
+
const x = cursor.x * CELL_WIDTH;
|
|
519
|
+
const y = cursor.y * CELL_HEIGHT;
|
|
520
|
+
if (cursor.shape === 3 || cursor.shape === 4)
|
|
521
|
+
this.solid(x, y + 18, CELL_WIDTH, 2, color);
|
|
522
|
+
else if (cursor.shape === 5 || cursor.shape === 6)
|
|
523
|
+
this.solid(x, y, 2, CELL_HEIGHT, color);
|
|
524
|
+
else {
|
|
525
|
+
color[3] *= 0.55;
|
|
526
|
+
this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const usedBytes = this.quadCount * STRIDE * 4;
|
|
530
|
+
if (!this.instanceBuffer || usedBytes > this.instanceBufferBytes) {
|
|
531
|
+
this.instanceBuffer?.destroy();
|
|
532
|
+
this.instanceBufferBytes = Math.max(256, this.instances.byteLength);
|
|
533
|
+
this.instanceBuffer = this.device.createBuffer({
|
|
534
|
+
size: this.instanceBufferBytes,
|
|
535
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
if (usedBytes)
|
|
539
|
+
this.device.queue.writeBuffer(this.instanceBuffer, 0, this.instances, 0, this.quadCount * STRIDE);
|
|
540
|
+
const encoder = this.device.createCommandEncoder();
|
|
541
|
+
const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);
|
|
542
|
+
const pass = encoder.beginRenderPass({
|
|
543
|
+
colorAttachments: [{
|
|
544
|
+
view: this.context.getCurrentTexture().createView(),
|
|
545
|
+
clearValue: { r: base[0], g: base[1], b: base[2], a: 1 },
|
|
546
|
+
loadOp: "clear",
|
|
547
|
+
storeOp: "store",
|
|
548
|
+
}],
|
|
549
|
+
});
|
|
550
|
+
pass.setPipeline(this.pipeline);
|
|
551
|
+
pass.setVertexBuffer(0, this.instanceBuffer);
|
|
552
|
+
for (const batch of this.batches) {
|
|
553
|
+
pass.setBindGroup(0, batch.resource.bindGroup);
|
|
554
|
+
pass.draw(6, batch.count, 0, batch.start);
|
|
555
|
+
}
|
|
556
|
+
pass.end();
|
|
557
|
+
this.device.queue.submit([encoder.finish()]);
|
|
558
|
+
return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };
|
|
559
|
+
}
|
|
560
|
+
metrics() {
|
|
561
|
+
return {
|
|
562
|
+
fontFamily: this.font.family,
|
|
563
|
+
rasterScale: this.scale,
|
|
564
|
+
backingScale: this.backingScale,
|
|
565
|
+
backingWidth: this.canvas.width,
|
|
566
|
+
backingHeight: this.canvas.height,
|
|
567
|
+
imageCount: this.images.size,
|
|
568
|
+
textureBytes: this.textureBytes,
|
|
569
|
+
atlasGlyphs: this.glyphs.size,
|
|
570
|
+
atlasBytes: this.atlas.width * this.atlas.height * 4,
|
|
571
|
+
atlasRebuilds: this.atlasRebuilds,
|
|
572
|
+
imageUploadBytes: this.imageUploadBytes,
|
|
573
|
+
imagePayloadBytes: this.imagePayloadBytes,
|
|
574
|
+
glyphUploadBytes: this.glyphUploadBytes,
|
|
575
|
+
instanceBufferBytes: this.instanceBufferBytes,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
async idle() {
|
|
579
|
+
await this.device.queue.onSubmittedWorkDone();
|
|
580
|
+
}
|
|
581
|
+
dispose() {
|
|
582
|
+
this.disposed = true;
|
|
583
|
+
for (const image of this.images.values())
|
|
584
|
+
image.texture.destroy();
|
|
585
|
+
this.images.clear();
|
|
586
|
+
this.atlas?.texture.destroy();
|
|
587
|
+
this.instanceBuffer?.destroy();
|
|
588
|
+
this.uniform?.destroy();
|
|
589
|
+
this.font?.dispose();
|
|
590
|
+
this.fontMetrics.clear();
|
|
591
|
+
this.context.unconfigure();
|
|
592
|
+
this.device.destroy();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
//# sourceMappingURL=renderer.js.map
|