Skip to content

Commit 32c0fa8

Browse files
simonkleebresilla
andcommitted
core: preserve terminal color intent
Flattening indexed and default colors into RGBA snapshots makes palette-relative roles lose meaning before the renderer sees them. Carry a color tag alongside RGBA through the TS, FFI, buffer, and native renderer, and publish the active terminal palette with epoch invalidation so the renderer can emit 39/49, keep 38;5 slots stable, and remap RGB fallback after palette changes instead of reusing stale indices Co-authored-by: Trim Bresilla <trim.bresilla@gmail.com>
1 parent 254da31 commit 32c0fa8

23 files changed

+1981
-317
lines changed

packages/core/src/buffer.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export class OptimizedBuffer {
5454
bg: Float32Array
5555
attributes: Uint32Array
5656
} | null = null
57+
private _rawColorTags: {
58+
fg: Uint16Array
59+
bg: Uint16Array
60+
} | null = null
5761
private _destroyed: boolean = false
5862

5963
get ptr(): Pointer {
@@ -67,29 +71,47 @@ export class OptimizedBuffer {
6771
if (this._destroyed) throw new Error(`Buffer ${this.id} is destroyed`)
6872
}
6973

74+
private ensureRawBufferViews(): void {
75+
if (this._rawBuffers !== null && this._rawColorTags !== null) {
76+
return
77+
}
78+
79+
const size = this._width * this._height
80+
const charPtr = this.lib.bufferGetCharPtr(this.bufferPtr)
81+
const fgPtr = this.lib.bufferGetFgPtr(this.bufferPtr)
82+
const bgPtr = this.lib.bufferGetBgPtr(this.bufferPtr)
83+
const fgTagPtr = this.lib.bufferGetFgTagPtr(this.bufferPtr)
84+
const bgTagPtr = this.lib.bufferGetBgTagPtr(this.bufferPtr)
85+
const attributesPtr = this.lib.bufferGetAttributesPtr(this.bufferPtr)
86+
87+
this._rawBuffers = {
88+
char: new Uint32Array(toArrayBuffer(charPtr, 0, size * 4)),
89+
fg: new Float32Array(toArrayBuffer(fgPtr, 0, size * 4 * 4)),
90+
bg: new Float32Array(toArrayBuffer(bgPtr, 0, size * 4 * 4)),
91+
attributes: new Uint32Array(toArrayBuffer(attributesPtr, 0, size * 4)),
92+
}
93+
94+
this._rawColorTags = {
95+
fg: new Uint16Array(toArrayBuffer(fgTagPtr, 0, size * 2)),
96+
bg: new Uint16Array(toArrayBuffer(bgTagPtr, 0, size * 2)),
97+
}
98+
}
99+
70100
get buffers(): {
71101
char: Uint32Array
72102
fg: Float32Array
73103
bg: Float32Array
74104
attributes: Uint32Array
75105
} {
76106
this.guard()
77-
if (this._rawBuffers === null) {
78-
const size = this._width * this._height
79-
const charPtr = this.lib.bufferGetCharPtr(this.bufferPtr)
80-
const fgPtr = this.lib.bufferGetFgPtr(this.bufferPtr)
81-
const bgPtr = this.lib.bufferGetBgPtr(this.bufferPtr)
82-
const attributesPtr = this.lib.bufferGetAttributesPtr(this.bufferPtr)
83-
84-
this._rawBuffers = {
85-
char: new Uint32Array(toArrayBuffer(charPtr, 0, size * 4)),
86-
fg: new Float32Array(toArrayBuffer(fgPtr, 0, size * 4 * 4)),
87-
bg: new Float32Array(toArrayBuffer(bgPtr, 0, size * 4 * 4)),
88-
attributes: new Uint32Array(toArrayBuffer(attributesPtr, 0, size * 4)),
89-
}
90-
}
107+
this.ensureRawBufferViews()
108+
return this._rawBuffers!
109+
}
91110

92-
return this._rawBuffers
111+
private get rawColorTags(): { fg: Uint16Array; bg: Uint16Array } {
112+
this.guard()
113+
this.ensureRawBufferViews()
114+
return this._rawColorTags!
93115
}
94116

95117
constructor(
@@ -155,6 +177,7 @@ export class OptimizedBuffer {
155177
public getSpanLines(): CapturedLine[] {
156178
this.guard()
157179
const { char, fg, bg, attributes } = this.buffers
180+
const { fg: fgTag, bg: bgTag } = this.rawColorTags
158181
const lines: CapturedLine[] = []
159182

160183
const CHAR_FLAG_CONTINUATION = 0xc0000000 | 0
@@ -173,8 +196,8 @@ export class OptimizedBuffer {
173196
for (let x = 0; x < this._width; x++) {
174197
const i = y * this._width + x
175198
const cp = char[i]
176-
const cellFg = RGBA.fromValues(fg[i * 4], fg[i * 4 + 1], fg[i * 4 + 2], fg[i * 4 + 3])
177-
const cellBg = RGBA.fromValues(bg[i * 4], bg[i * 4 + 1], bg[i * 4 + 2], bg[i * 4 + 3])
199+
const cellFg = RGBA.fromValues(fg[i * 4], fg[i * 4 + 1], fg[i * 4 + 2], fg[i * 4 + 3], fgTag[i])
200+
const cellBg = RGBA.fromValues(bg[i * 4], bg[i * 4 + 1], bg[i * 4 + 2], bg[i * 4 + 3], bgTag[i])
178201
const cellAttrs = attributes[i] & 0xff
179202

180203
// Continuation cells are placeholders for wide characters (emojis, CJK)
@@ -422,6 +445,7 @@ export class OptimizedBuffer {
422445
this._width = width
423446
this._height = height
424447
this._rawBuffers = null
448+
this._rawColorTags = null
425449

426450
this.lib.bufferResize(this.bufferPtr, width, height)
427451
}

packages/core/src/lib/RGBA.test.ts

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,62 @@
11
import { test, expect, describe } from "bun:test"
2-
import { RGBA, hexToRgb, rgbToHex, hsvToRgb, parseColor } from "./RGBA.js"
2+
import {
3+
COLOR_TAG_DEFAULT,
4+
COLOR_TAG_RGB,
5+
RGBA,
6+
decodeColorTag,
7+
hexToRgb,
8+
hsvToRgb,
9+
normalizeColorValue,
10+
parseColor,
11+
rgbToHex,
12+
} from "./RGBA.js"
313

414
describe("RGBA class", () => {
515
describe("constructor", () => {
6-
test("creates RGBA with Float32Array buffer", () => {
7-
const buffer = new Float32Array([0.5, 0.6, 0.7, 0.8])
16+
test("uses the provided 5-float buffer", () => {
17+
const buffer = new Float32Array([0.5, 0.6, 0.7, 0.8, COLOR_TAG_RGB])
818
const rgba = new RGBA(buffer)
919
expect(rgba.buffer).toBe(buffer)
20+
expect(rgba.tag).toBe(COLOR_TAG_RGB)
1021
})
1122

12-
test("buffer is mutable reference", () => {
23+
test("upgrades legacy 4-float buffers to explicit RGB tag", () => {
1324
const buffer = new Float32Array([0.5, 0.6, 0.7, 0.8])
1425
const rgba = new RGBA(buffer)
26+
27+
expect(rgba.buffer).not.toBe(buffer)
28+
expect(rgba.buffer).toHaveLength(5)
29+
expect(rgba.r).toBeCloseTo(0.5, 5)
30+
expect(rgba.g).toBeCloseTo(0.6, 5)
31+
expect(rgba.b).toBeCloseTo(0.7, 5)
32+
expect(rgba.a).toBeCloseTo(0.8, 5)
33+
expect(rgba.tag).toBe(COLOR_TAG_RGB)
34+
})
35+
36+
test("buffer is mutable reference when already 5-float", () => {
37+
const buffer = new Float32Array([0.5, 0.6, 0.7, 0.8, COLOR_TAG_RGB])
38+
const rgba = new RGBA(buffer)
1539
buffer[0] = 0.9
1640
expect(rgba.r).toBeCloseTo(0.9, 5)
1741
})
1842
})
1943

2044
describe("fromArray", () => {
21-
test("creates RGBA from Float32Array", () => {
45+
test("creates RGBA from legacy 4-float arrays", () => {
2246
const array = new Float32Array([0.1, 0.2, 0.3, 0.4])
2347
const rgba = RGBA.fromArray(array)
2448
expect(rgba.r).toBeCloseTo(0.1, 5)
2549
expect(rgba.g).toBeCloseTo(0.2, 5)
2650
expect(rgba.b).toBeCloseTo(0.3, 5)
2751
expect(rgba.a).toBeCloseTo(0.4, 5)
52+
expect(rgba.tag).toBe(COLOR_TAG_RGB)
2853
})
2954

30-
test("uses same buffer reference", () => {
31-
const array = new Float32Array([0.1, 0.2, 0.3, 0.4])
55+
test("uses same buffer reference when already 5-float", () => {
56+
const array = new Float32Array([0.1, 0.2, 0.3, 0.4, COLOR_TAG_DEFAULT])
3257
const rgba = RGBA.fromArray(array)
3358
expect(rgba.buffer).toBe(array)
59+
expect(rgba.tag).toBe(COLOR_TAG_DEFAULT)
3460
})
3561
})
3662

@@ -120,6 +146,69 @@ describe("RGBA class", () => {
120146
})
121147
})
122148

149+
describe("clone", () => {
150+
test("creates a detached copy", () => {
151+
const original = RGBA.fromValues(0.1, 0.2, 0.3, 0.4)
152+
const cloned = RGBA.clone(original)
153+
expect(cloned).not.toBe(original)
154+
expect(cloned.buffer).not.toBe(original.buffer)
155+
expect(cloned.toInts()).toEqual(original.toInts())
156+
cloned.r = 0.9
157+
expect(original.r).toBeCloseTo(0.1, 5)
158+
})
159+
})
160+
161+
describe("intent helpers", () => {
162+
test("fromIndex uses ANSI256 fallback snapshots", () => {
163+
expect(RGBA.fromIndex(9).toInts()).toEqual([255, 0, 0, 255])
164+
expect(RGBA.fromIndex(21).toInts()).toEqual([0, 0, 255, 255])
165+
expect(RGBA.fromIndex(232).toInts()).toEqual([8, 8, 8, 255])
166+
expect(RGBA.fromIndex(255).toInts()).toEqual([238, 238, 238, 255])
167+
})
168+
169+
test("stores explicit tags in the fifth float", () => {
170+
const rgb = RGBA.fromHex("#112233")
171+
const indexed = RGBA.fromIndex(6)
172+
const defaultFg = RGBA.defaultForeground()
173+
174+
expect(rgb.buffer).toHaveLength(5)
175+
expect(rgb.buffer[4]).toBe(COLOR_TAG_RGB)
176+
expect(indexed.buffer[4]).toBe(6)
177+
expect(defaultFg.buffer[4]).toBe(COLOR_TAG_DEFAULT)
178+
})
179+
180+
test("does not mutate caller-owned snapshots when constructing tagged colors", () => {
181+
const indexedSnapshot = RGBA.fromHex("#112233")
182+
const defaultSnapshot = RGBA.fromHex("#abcdef")
183+
184+
const indexed = RGBA.fromIndex(6, indexedSnapshot)
185+
const defaultFg = RGBA.defaultForeground(defaultSnapshot)
186+
187+
expect(indexed).not.toBe(indexedSnapshot)
188+
expect(defaultFg).not.toBe(defaultSnapshot)
189+
expect(RGBA.getIntentTag(indexedSnapshot)).toBe(COLOR_TAG_RGB)
190+
expect(RGBA.getIntentTag(defaultSnapshot)).toBe(COLOR_TAG_RGB)
191+
expect(RGBA.getIntentTag(indexed)).toBe(6)
192+
expect(RGBA.getIntentTag(defaultFg)).toBe(COLOR_TAG_DEFAULT)
193+
expect(indexed.toInts()).toEqual(indexedSnapshot.toInts())
194+
expect(defaultFg.toInts()).toEqual(defaultSnapshot.toInts())
195+
})
196+
197+
test("normalizeColorValue and decodeColorTag preserve color intent", () => {
198+
expect(normalizeColorValue(null)).toBeNull()
199+
expect(
200+
[RGBA.fromHex("#123456"), RGBA.fromIndex(12), RGBA.defaultBackground()].map((color) => [
201+
normalizeColorValue(color)?.tag,
202+
decodeColorTag(color.tag),
203+
]),
204+
).toEqual([
205+
[COLOR_TAG_RGB, { kind: "rgb" }],
206+
[12, { kind: "indexed", index: 12 }],
207+
[COLOR_TAG_DEFAULT, { kind: "default" }],
208+
])
209+
})
210+
})
211+
123212
describe("fromHex", () => {
124213
test("creates RGBA from hex string", () => {
125214
const rgba = RGBA.fromHex("#FF8040")
@@ -274,6 +363,14 @@ describe("RGBA class", () => {
274363
})
275364
})
276365

366+
describe("equals", () => {
367+
test("compares both rgba values and tags", () => {
368+
const rgb = RGBA.fromHex("#112233")
369+
expect(rgb.equals(RGBA.fromIndex(6, rgb))).toBe(false)
370+
expect(RGBA.defaultForeground("#aabbcc").equals(RGBA.defaultForeground("#aabbcc"))).toBe(true)
371+
})
372+
})
373+
277374
describe("toString", () => {
278375
test("formats as rgba string with 2 decimal places", () => {
279376
const rgba = RGBA.fromValues(0.5, 0.6, 0.7, 0.8)

0 commit comments

Comments
 (0)