perf: optimize color remapping with Uint32Array lookup
Replace Map-based color lookup with Uint32Array hash table for O(1) lookups without string allocation. Patch at WASM getLine level (once per line) instead of renderCell (once per cell) for better performance on high-throughput terminal output.
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# Feature: WASM terminal should respect theme palette for ANSI colors
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The WASM terminal parser/renderer has a hardcoded color palette (Tomorrow Night) that doesn't respect the theme passed to the Terminal constructor. When an application sends ANSI color codes, they get resolved to Tomorrow Night RGB values regardless of the configured theme.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
When creating a terminal with a custom theme:
|
||||||
|
```typescript
|
||||||
|
const terminal = new Terminal({
|
||||||
|
theme: {
|
||||||
|
background: '#002b36', // solarized
|
||||||
|
foreground: '#839496',
|
||||||
|
green: '#859900',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The WASM parser still outputs Tomorrow Night colors (e.g., `#b5bd68` for green instead of `#859900`), because the internal WASM module resolves ANSI codes to its built-in palette.
|
||||||
|
|
||||||
|
The renderer uses the theme for canvas background/cursor/selection, but text colors come pre-resolved from WASM.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
ANSI color codes should resolve to the user's configured theme palette, not the hardcoded Tomorrow Night palette.
|
||||||
|
|
||||||
|
## Workaround
|
||||||
|
|
||||||
|
We currently patch `renderer.renderCell` to intercept and remap colors from Tomorrow Night → custom theme using a color map. This works but requires knowing the exact Tomorrow Night palette values and adds overhead to every cell render.
|
||||||
|
|
||||||
|
## Proposed Solutions
|
||||||
|
|
||||||
|
1. **Pass theme palette to WASM** - Allow the palette to be configured when initializing the WASM module
|
||||||
|
2. **Return color indices** - Have WASM return ANSI color indices (0-15) rather than resolved RGB, letting the renderer resolve them
|
||||||
|
3. **Document the internal palette** - At minimum, document that Tomorrow Night is the hardcoded palette so consumers can build their own remapping
|
||||||
|
|
||||||
|
Option 2 would be cleanest as it separates parsing from rendering.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- ghostty-web: 0.4.0
|
||||||
|
- Browser: All
|
||||||
|
|
||||||
|
---
|
||||||
|
*Filed from textual-webterm project where we encountered this while implementing theme support*
|
||||||
File diff suppressed because one or more lines are too long
@@ -315,11 +315,21 @@ function hexToRgb(hex: string): [number, number, number] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a color map from default palette RGB to theme palette RGB */
|
/**
|
||||||
function buildColorMap(theme: ITheme): Map<string, [number, number, number]> {
|
* Build a fast lookup table for color remapping.
|
||||||
const colorMap = new Map<string, [number, number, number]>();
|
* Uses a flat Uint8Array indexed by packed RGB (r << 16 | g << 8 | b) % tableSize.
|
||||||
|
* Returns null for unmapped colors (non-palette colors like 256-color/truecolor).
|
||||||
|
*/
|
||||||
|
function buildColorLookup(theme: ITheme): {
|
||||||
|
lookup: Uint32Array;
|
||||||
|
hasMapping: Uint8Array;
|
||||||
|
} {
|
||||||
|
// Use a hash table with separate chaining avoided by using unique keys
|
||||||
|
// Since we only have 18 colors, a small table with direct indexing works
|
||||||
|
const TABLE_SIZE = 65536; // 2^16 - good for sparse lookups
|
||||||
|
const lookup = new Uint32Array(TABLE_SIZE);
|
||||||
|
const hasMapping = new Uint8Array(TABLE_SIZE);
|
||||||
|
|
||||||
// Map each default color to the corresponding theme color
|
|
||||||
const colorKeys: (keyof ITheme)[] = [
|
const colorKeys: (keyof ITheme)[] = [
|
||||||
'foreground', 'background',
|
'foreground', 'background',
|
||||||
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
|
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
|
||||||
@@ -331,103 +341,120 @@ function buildColorMap(theme: ITheme): Map<string, [number, number, number]> {
|
|||||||
const defaultColor = GHOSTTY_DEFAULT_PALETTE[key];
|
const defaultColor = GHOSTTY_DEFAULT_PALETTE[key];
|
||||||
const themeColor = theme[key];
|
const themeColor = theme[key];
|
||||||
if (defaultColor && themeColor) {
|
if (defaultColor && themeColor) {
|
||||||
const defaultRgb = hexToRgb(defaultColor);
|
const [dr, dg, db] = hexToRgb(defaultColor);
|
||||||
const themeRgb = hexToRgb(themeColor);
|
const [tr, tg, tb] = hexToRgb(themeColor);
|
||||||
// Key is "r,g,b" string for fast lookup
|
// Hash: use lower bits of combined RGB
|
||||||
const keyStr = `${defaultRgb[0]},${defaultRgb[1]},${defaultRgb[2]}`;
|
const hash = ((dr * 31 + dg) * 31 + db) & (TABLE_SIZE - 1);
|
||||||
colorMap.set(keyStr, themeRgb);
|
// Pack theme RGB into uint32: r | g << 8 | b << 16
|
||||||
|
lookup[hash] = tr | (tg << 8) | (tb << 16);
|
||||||
|
hasMapping[hash] = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return colorMap;
|
return { lookup, hasMapping };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute hash for RGB color (must match buildColorLookup) */
|
||||||
|
function colorHash(r: number, g: number, b: number): number {
|
||||||
|
return ((r * 31 + g) * 31 + b) & 65535;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patch the renderer to remap colors from ghostty-web's default palette
|
* Patch the WASM terminal's getLine method to remap colors at the source.
|
||||||
* to our custom theme palette.
|
* This is more efficient than patching renderCell since it processes
|
||||||
*
|
* colors once per line fetch rather than once per cell render.
|
||||||
* This is necessary because ghostty-web's WASM terminal has a hardcoded
|
|
||||||
* palette and returns pre-resolved RGB values. The theme we pass to the
|
|
||||||
* renderer is only used for background/cursor/selection, not for text colors.
|
|
||||||
*/
|
*/
|
||||||
function patchRendererColors(terminal: Terminal, theme: ITheme): void {
|
function patchWasmColors(terminal: Terminal, theme: ITheme): void {
|
||||||
const internalTerminal = terminal as unknown as Record<string, unknown>;
|
const internalTerminal = terminal as unknown as Record<string, unknown>;
|
||||||
const renderer = internalTerminal.renderer as Record<string, unknown> | undefined;
|
const wasmTerminal = internalTerminal.terminal as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (!renderer) {
|
if (!wasmTerminal) {
|
||||||
console.warn("[webterm:patch] No renderer found, cannot patch colors");
|
console.warn("[webterm:patch] No WASM terminal found, cannot patch colors");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorMap = buildColorMap(theme);
|
const { lookup, hasMapping } = buildColorLookup(theme);
|
||||||
console.log("[webterm:patch] Built color map with", colorMap.size, "entries");
|
console.log("[webterm:patch] Built optimized color lookup table");
|
||||||
// Log a few mappings for debugging
|
|
||||||
for (const [key, value] of colorMap.entries()) {
|
|
||||||
console.log(`[webterm:patch] Color map: ${key} -> ${value.join(",")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store original renderCell method
|
// Patch getLine to remap colors in returned cells
|
||||||
const originalRenderCell = renderer.renderCell as (
|
const originalGetLine = wasmTerminal.getLine as (row: number) => Array<{
|
||||||
cell: { fg_r: number; fg_g: number; fg_b: number; bg_r: number; bg_g: number; bg_b: number; [key: string]: unknown },
|
fg_r: number; fg_g: number; fg_b: number;
|
||||||
col: number,
|
bg_r: number; bg_g: number; bg_b: number;
|
||||||
row: number
|
[key: string]: unknown;
|
||||||
) => void;
|
}> | null;
|
||||||
|
|
||||||
if (!originalRenderCell) {
|
if (!originalGetLine) {
|
||||||
console.warn("[webterm:patch] No renderCell method found");
|
console.warn("[webterm:patch] No getLine method found on WASM terminal");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let patchLogCount = 0;
|
wasmTerminal.getLine = function(row: number) {
|
||||||
const maxPatchLogs = 20;
|
const cells = originalGetLine.call(this, row);
|
||||||
const seenColors = new Set<string>();
|
if (!cells) return cells;
|
||||||
|
|
||||||
// Patch renderCell to remap colors
|
// Remap colors in-place for performance
|
||||||
renderer.renderCell = function(
|
for (let i = 0; i < cells.length; i++) {
|
||||||
cell: { fg_r: number; fg_g: number; fg_b: number; bg_r: number; bg_g: number; bg_b: number; [key: string]: unknown },
|
const cell = cells[i];
|
||||||
col: number,
|
|
||||||
row: number
|
|
||||||
) {
|
|
||||||
// Log unique colors we see from WASM (for debugging)
|
|
||||||
const fgKey = `${cell.fg_r},${cell.fg_g},${cell.fg_b}`;
|
|
||||||
const bgKey = `${cell.bg_r},${cell.bg_g},${cell.bg_b}`;
|
|
||||||
|
|
||||||
if (patchLogCount < maxPatchLogs) {
|
// Remap foreground
|
||||||
if (!seenColors.has(`fg:${fgKey}`)) {
|
const fgHash = colorHash(cell.fg_r, cell.fg_g, cell.fg_b);
|
||||||
seenColors.add(`fg:${fgKey}`);
|
if (hasMapping[fgHash]) {
|
||||||
const inMap = colorMap.has(fgKey);
|
const packed = lookup[fgHash];
|
||||||
console.log(`[webterm:patch] WASM fg color: ${fgKey} (in map: ${inMap})`);
|
cell.fg_r = packed & 0xff;
|
||||||
patchLogCount++;
|
cell.fg_g = (packed >> 8) & 0xff;
|
||||||
|
cell.fg_b = (packed >> 16) & 0xff;
|
||||||
}
|
}
|
||||||
if (!seenColors.has(`bg:${bgKey}`)) {
|
|
||||||
seenColors.add(`bg:${bgKey}`);
|
// Remap background
|
||||||
const inMap = colorMap.has(bgKey);
|
const bgHash = colorHash(cell.bg_r, cell.bg_g, cell.bg_b);
|
||||||
console.log(`[webterm:patch] WASM bg color: ${bgKey} (in map: ${inMap})`);
|
if (hasMapping[bgHash]) {
|
||||||
patchLogCount++;
|
const packed = lookup[bgHash];
|
||||||
|
cell.bg_r = packed & 0xff;
|
||||||
|
cell.bg_g = (packed >> 8) & 0xff;
|
||||||
|
cell.bg_b = (packed >> 16) & 0xff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap foreground color
|
return cells;
|
||||||
const mappedFg = colorMap.get(fgKey);
|
|
||||||
if (mappedFg) {
|
|
||||||
cell.fg_r = mappedFg[0];
|
|
||||||
cell.fg_g = mappedFg[1];
|
|
||||||
cell.fg_b = mappedFg[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remap background color
|
|
||||||
const mappedBg = colorMap.get(bgKey);
|
|
||||||
if (mappedBg) {
|
|
||||||
cell.bg_r = mappedBg[0];
|
|
||||||
cell.bg_g = mappedBg[1];
|
|
||||||
cell.bg_b = mappedBg[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call original method with remapped colors
|
|
||||||
return originalRenderCell.call(this, cell, col, row);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[webterm:patch] Successfully patched renderer.renderCell for theme colors");
|
// Also patch getScrollbackLine for scrollback rendering
|
||||||
|
const originalGetScrollbackLine = wasmTerminal.getScrollbackLine as (offset: number) => Array<{
|
||||||
|
fg_r: number; fg_g: number; fg_b: number;
|
||||||
|
bg_r: number; bg_g: number; bg_b: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}> | null;
|
||||||
|
|
||||||
|
if (originalGetScrollbackLine) {
|
||||||
|
wasmTerminal.getScrollbackLine = function(offset: number) {
|
||||||
|
const cells = originalGetScrollbackLine.call(this, offset);
|
||||||
|
if (!cells) return cells;
|
||||||
|
|
||||||
|
for (let i = 0; i < cells.length; i++) {
|
||||||
|
const cell = cells[i];
|
||||||
|
|
||||||
|
const fgHash = colorHash(cell.fg_r, cell.fg_g, cell.fg_b);
|
||||||
|
if (hasMapping[fgHash]) {
|
||||||
|
const packed = lookup[fgHash];
|
||||||
|
cell.fg_r = packed & 0xff;
|
||||||
|
cell.fg_g = (packed >> 8) & 0xff;
|
||||||
|
cell.fg_b = (packed >> 16) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgHash = colorHash(cell.bg_r, cell.bg_g, cell.bg_b);
|
||||||
|
if (hasMapping[bgHash]) {
|
||||||
|
const packed = lookup[bgHash];
|
||||||
|
cell.bg_r = packed & 0xff;
|
||||||
|
cell.bg_g = (packed >> 8) & 0xff;
|
||||||
|
cell.bg_b = (packed >> 16) & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[webterm:patch] Successfully patched WASM getLine/getScrollbackLine for theme colors");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configuration options passed via data attributes or window config */
|
/** Configuration options passed via data attributes or window config */
|
||||||
@@ -587,8 +614,8 @@ class WebTerminal {
|
|||||||
await terminal.open(container);
|
await terminal.open(container);
|
||||||
console.log("[webterm:create] terminal.open() completed");
|
console.log("[webterm:create] terminal.open() completed");
|
||||||
|
|
||||||
// Patch renderer to remap colors from ghostty-web's default palette to our theme
|
// Patch WASM terminal to remap colors from ghostty-web's default palette to our theme
|
||||||
patchRendererColors(terminal, themeToUse);
|
patchWasmColors(terminal, themeToUse);
|
||||||
|
|
||||||
// Check internal state after open
|
// Check internal state after open
|
||||||
const internalTerminal = terminal as unknown as Record<string, unknown>;
|
const internalTerminal = terminal as unknown as Record<string, unknown>;
|
||||||
|
|||||||
Reference in New Issue
Block a user