diff --git a/packages/core/src/buffer.ts b/packages/core/src/buffer.ts index a3f69cce3..0345ea1c8 100644 --- a/packages/core/src/buffer.ts +++ b/packages/core/src/buffer.ts @@ -219,12 +219,23 @@ export class OptimizedBuffer { public clear(bg: RGBA = RGBA.fromValues(0, 0, 0, 1)): void { this.guard() - this.lib.bufferClear(this.bufferPtr, bg) + const hasColorTypeInfo = bg.colorType !== undefined + if (hasColorTypeInfo) { + this.lib.bufferClearWithColorType(this.bufferPtr, bg) + } else { + this.lib.bufferClear(this.bufferPtr, bg) + } } public setCell(x: number, y: number, char: string, fg: RGBA, bg: RGBA, attributes: number = 0): void { this.guard() - this.lib.bufferSetCell(this.bufferPtr, x, y, char, fg, bg, attributes) + // Check if either color has color type info (indexed or default) + const hasColorTypeInfo = fg.colorType !== undefined || bg.colorType !== undefined + if (hasColorTypeInfo) { + this.lib.bufferSetCellWithColorType(this.bufferPtr, x, y, char, fg, bg, attributes) + } else { + this.lib.bufferSetCell(this.bufferPtr, x, y, char, fg, bg, attributes) + } } public setCellWithAlphaBlending( @@ -236,7 +247,13 @@ export class OptimizedBuffer { attributes: number = 0, ): void { this.guard() - this.lib.bufferSetCellWithAlphaBlending(this.bufferPtr, x, y, char, fg, bg, attributes) + // Check if either color has color type info (indexed or default) + const hasColorTypeInfo = fg.colorType !== undefined || bg.colorType !== undefined + if (hasColorTypeInfo) { + this.lib.bufferSetCellWithAlphaBlendingAndColorType(this.bufferPtr, x, y, char, fg, bg, attributes) + } else { + this.lib.bufferSetCellWithAlphaBlending(this.bufferPtr, x, y, char, fg, bg, attributes) + } } public drawText( diff --git a/packages/core/src/examples/terminal_ansi.ts b/packages/core/src/examples/terminal_ansi.ts new file mode 100644 index 000000000..b92be1754 --- /dev/null +++ b/packages/core/src/examples/terminal_ansi.ts @@ -0,0 +1,500 @@ +#!/usr/bin/env bun + +import { + CliRenderer, + createCliRenderer, + RGBA, + TextAttributes, + TextRenderable, + FrameBufferRenderable, + BoxRenderable, + t, + fg, + bg, + bold, + underline, +} from "../index" +import { ScrollBoxRenderable } from "../renderables/ScrollBox" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +/** + * This demo showcases the indexed color (0-255) and default color support. + * + * Features demonstrated: + * - Standard 16 ANSI colors (0-15) + * - 216-color cube (16-231) + * - 24 grayscale colors (232-255) + * - Default foreground/background colors + * - Using indexed colors via "ansi:N" string format + * - Using indexed colors via RGBA.fromIndex(N) + * - Using indexed colors via { index: N } object + */ + +let scrollBox: ScrollBoxRenderable | null = null +let contentContainer: BoxRenderable | null = null +let keyboardHandler: ((key: any) => void) | null = null + +export function run(renderer: CliRenderer): void { + renderer.start() + // Follow the terminal's current background (OSC 11 / SGR 49). + // This is the most reliable way to track pywal background changes. + renderer.setBackgroundColor("default") + + const mainContainer = new BoxRenderable(renderer, { + id: "main-container", + flexGrow: 1, + flexDirection: "column", + }) + renderer.root.add(mainContainer) + + scrollBox = new ScrollBoxRenderable(renderer, { + id: "ansi-scroll-box", + stickyScroll: false, + border: true, + // Use a base-16 ANSI color so themes like pywal can remap it. + borderColor: "ansi:1", + // Don't paint a fixed background; let the terminal default show through. + backgroundColor: "transparent", + title: "Indexed Colors Demo (0-255) | Scroll with arrows | ESC to exit", + titleAlignment: "center", + contentOptions: { + paddingLeft: 2, + paddingRight: 2, + paddingTop: 1, + }, + }) + mainContainer.add(scrollBox) + + contentContainer = new BoxRenderable(renderer, { + id: "ansi-content-container", + width: "auto", + flexDirection: "column", + }) + scrollBox.add(contentContainer) + + // Section 1: Introduction + const introText = new TextRenderable(renderer, { + id: "intro-text", + content: t`${bold("Indexed Colors (ANSI 256)")} + +This demo shows the new indexed color support. Indexed colors use SGR 38;5;N (fg) and 48;5;N (bg) +escape sequences, which are more efficient than true color (24-bit RGB) sequences. + +${underline("Color ranges:")} + 0-7: Standard colors (dark) + 8-15: High-intensity colors (bright) + 16-231: 216-color cube (6x6x6) + 232-255: Grayscale (24 shades) +`, + fg: RGBA.fromInts(226, 232, 240), + }) + contentContainer.add(introText) + + // Section 2: Standard 16 colors (0-15) + const standardColorsTitle = new TextRenderable(renderer, { + id: "standard-colors-title", + content: t`${bold(fg("#38BDF8")("Standard 16 Colors (0-15)"))}`, + marginTop: 1, + }) + contentContainer.add(standardColorsTitle) + + const standardColorsBuffer = new FrameBufferRenderable(renderer, { + id: "standard-colors-buffer", + width: 64, + height: 4, + marginTop: 1, + }) + contentContainer.add(standardColorsBuffer) + drawStandard16Colors(standardColorsBuffer) + + // Section 3: 216-color cube (16-231) + const cubeTitle = new TextRenderable(renderer, { + id: "cube-title", + content: t`${bold(fg("#38BDF8")("216-Color Cube (16-231)"))}`, + marginTop: 2, + }) + contentContainer.add(cubeTitle) + + const cubeDescription = new TextRenderable(renderer, { + id: "cube-description", + content: "6x6x6 RGB color cube - each axis has 6 levels (0, 95, 135, 175, 215, 255)", + fg: RGBA.fromInts(148, 163, 184), + }) + contentContainer.add(cubeDescription) + + const colorCubeBuffer = new FrameBufferRenderable(renderer, { + id: "color-cube-buffer", + width: 72, + height: 12, + marginTop: 1, + }) + contentContainer.add(colorCubeBuffer) + drawColorCube(colorCubeBuffer) + + // Section 4: Grayscale (232-255) + const grayscaleTitle = new TextRenderable(renderer, { + id: "grayscale-title", + content: t`${bold(fg("#38BDF8")("Grayscale Ramp (232-255)"))}`, + marginTop: 2, + }) + contentContainer.add(grayscaleTitle) + + const grayscaleBuffer = new FrameBufferRenderable(renderer, { + id: "grayscale-buffer", + width: 72, + height: 3, + marginTop: 1, + }) + contentContainer.add(grayscaleBuffer) + drawGrayscale(grayscaleBuffer) + + // Section 5: Default colors + const defaultColorsTitle = new TextRenderable(renderer, { + id: "default-colors-title", + content: t`${bold(fg("#38BDF8")("Default Colors"))}`, + marginTop: 2, + }) + contentContainer.add(defaultColorsTitle) + + const defaultColorsDescription = new TextRenderable(renderer, { + id: "default-colors-description", + content: `Default colors use SGR 39 (default fg) and SGR 49 (default bg). +These respect the user's terminal theme settings.`, + fg: RGBA.fromInts(148, 163, 184), + }) + contentContainer.add(defaultColorsDescription) + + const defaultColorsBuffer = new FrameBufferRenderable(renderer, { + id: "default-colors-buffer", + width: 60, + height: 4, + marginTop: 1, + }) + contentContainer.add(defaultColorsBuffer) + drawDefaultColors(defaultColorsBuffer) + + // Section 6: Usage examples + const usageTitle = new TextRenderable(renderer, { + id: "usage-title", + content: t`${bold(fg("#38BDF8")("Usage Examples"))}`, + marginTop: 2, + }) + contentContainer.add(usageTitle) + + const usageText = new TextRenderable(renderer, { + id: "usage-text", + content: t`${fg("#94A3B8")("// String format:")} +fg("ansi:196")("Red text") ${fg("ansi:196")("Red text")} +bg("ansi:21")("Blue background") ${bg("ansi:21")(fg("ansi:231")(" Blue background "))} + +${fg("#94A3B8")("// RGBA.fromIndex():")} +RGBA.fromIndex(46) ${fg("ansi:46")("Bright green")} + +${fg("#94A3B8")("// Object format:")} +{ index: 208 } ${fg("ansi:208")("Orange")} + +${fg("#94A3B8")("// Default colors:")} +fg("default") Uses terminal's default foreground +bg("default") Uses terminal's default background +`, + }) + contentContainer.add(usageText) + + // Section 7: Interactive color palette + const paletteTitle = new TextRenderable(renderer, { + id: "palette-title", + content: t`${bold(fg("#38BDF8")("Full 256-Color Palette"))}`, + marginTop: 2, + }) + contentContainer.add(paletteTitle) + + const fullPaletteBuffer = new FrameBufferRenderable(renderer, { + id: "full-palette-buffer", + width: 64, + height: 16, + marginTop: 1, + }) + contentContainer.add(fullPaletteBuffer) + drawFullPalette(fullPaletteBuffer) + + // Section 8: Gradient demonstration + const gradientTitle = new TextRenderable(renderer, { + id: "gradient-title", + content: t`${bold(fg("#38BDF8")("Color Gradients Using Indexed Colors"))}`, + marginTop: 2, + }) + contentContainer.add(gradientTitle) + + const gradientBuffer = new FrameBufferRenderable(renderer, { + id: "gradient-buffer", + width: 72, + height: 8, + marginTop: 1, + }) + contentContainer.add(gradientBuffer) + drawGradients(gradientBuffer) + + // Footer + const footerText = new TextRenderable(renderer, { + id: "footer-text", + content: t` +${fg("#64748B")("Press ESC to return to menu | Use arrow keys or j/k to scroll")}`, + marginTop: 2, + }) + contentContainer.add(footerText) + + // Keyboard handler + keyboardHandler = (key) => { + if (key.name === "j" || key.name === "down") { + scrollBox?.scrollBy(0, 1) + } else if (key.name === "k" || key.name === "up") { + scrollBox?.scrollBy(0, -1) + } else if (key.name === "pagedown" || (key.name === "d" && key.ctrl)) { + scrollBox?.scrollBy(0, 10) + } else if (key.name === "pageup" || (key.name === "u" && key.ctrl)) { + scrollBox?.scrollBy(0, -10) + } + } + renderer.keyInput.on("keypress", keyboardHandler) +} + +function drawStandard16Colors(buffer: FrameBufferRenderable): void { + const fb = buffer.frameBuffer + const defaultBg = RGBA.defaultBackground() + fb.clear(defaultBg) + + const labels = [ + "0:Blk", + "1:Red", + "2:Grn", + "3:Ylw", + "4:Blu", + "5:Mag", + "6:Cyn", + "7:Wht", + "8:BBlk", + "9:BRed", + "10:BGrn", + "11:BYlw", + "12:BBlu", + "13:BMag", + "14:BCyn", + "15:BWht", + ] + + // Draw 8 colors per row + for (let i = 0; i < 16; i++) { + const row = Math.floor(i / 8) + const col = i % 8 + const x = col * 8 + const y = row * 2 + + const bgColor = RGBA.fromIndex(i) + // Choose contrasting text color + const fgColor = i === 0 || i === 4 || i === 8 ? RGBA.fromInts(255, 255, 255) : RGBA.fromInts(0, 0, 0) + + // Draw background block + for (let dy = 0; dy < 2; dy++) { + for (let dx = 0; dx < 7; dx++) { + fb.setCell(x + dx, y + dy, " ", fgColor, bgColor) + } + } + + // Draw index number centered + const indexStr = i.toString().padStart(2, " ") + fb.drawText(indexStr[0], x + 2, y, fgColor, bgColor, TextAttributes.NONE) + fb.drawText(indexStr[1], x + 3, y, fgColor, bgColor, TextAttributes.NONE) + } +} + +function drawColorCube(buffer: FrameBufferRenderable): void { + const fb = buffer.frameBuffer + const defaultBg = RGBA.defaultBackground() + fb.clear(defaultBg) + + // Draw the 6x6x6 color cube (216 colors from index 16-231) + // Arranged as 6 rows of 36 colors each + for (let r = 0; r < 6; r++) { + for (let g = 0; g < 6; g++) { + for (let b = 0; b < 6; b++) { + const index = 16 + r * 36 + g * 6 + b + const x = g * 6 + b + const y = r * 2 + + const bgColor = RGBA.fromIndex(index) + + // Draw 2-char wide block + fb.setCell(x * 2, y, " ", RGBA.fromInts(0, 0, 0), bgColor) + fb.setCell(x * 2 + 1, y, " ", RGBA.fromInts(0, 0, 0), bgColor) + fb.setCell(x * 2, y + 1, " ", RGBA.fromInts(0, 0, 0), bgColor) + fb.setCell(x * 2 + 1, y + 1, " ", RGBA.fromInts(0, 0, 0), bgColor) + } + } + } +} + +function drawGrayscale(buffer: FrameBufferRenderable): void { + const fb = buffer.frameBuffer + const defaultBg = RGBA.defaultBackground() + fb.clear(defaultBg) + + // Draw grayscale ramp (232-255) + for (let i = 0; i < 24; i++) { + const index = 232 + i + const x = i * 3 + + const bgColor = RGBA.fromIndex(index) + const fgColor = i < 12 ? RGBA.fromInts(255, 255, 255) : RGBA.fromInts(0, 0, 0) + + // Draw 3-char wide, 2-row block + for (let dy = 0; dy < 2; dy++) { + for (let dx = 0; dx < 3; dx++) { + fb.setCell(x + dx, dy, " ", fgColor, bgColor) + } + } + + // Draw index number + const indexStr = index.toString() + for (let ci = 0; ci < indexStr.length; ci++) { + fb.drawText(indexStr[ci], x + ci, 2, RGBA.fromIndex(7), defaultBg, TextAttributes.NONE) + } + } +} + +function drawDefaultColors(buffer: FrameBufferRenderable): void { + const fb = buffer.frameBuffer + const defaultBg = RGBA.defaultBackground() + fb.clear(defaultBg) + + const defaultFg = RGBA.defaultForeground() + // `defaultBg` already declared above + + // Row 1: Default foreground on various backgrounds + const text1 = "Default FG on indexed BGs: " + for (let i = 0; i < text1.length; i++) { + fb.drawText(text1[i], i, 0, RGBA.fromIndex(7), defaultBg, TextAttributes.NONE) + } + + const bgIndices = [0, 1, 2, 3, 4, 5, 6, 7] + for (let i = 0; i < bgIndices.length; i++) { + const bgColor = RGBA.fromIndex(bgIndices[i]) + fb.setCell(text1.length + i * 3, 0, " ", defaultFg, bgColor) + fb.setCell(text1.length + i * 3 + 1, 0, "X", defaultFg, bgColor) + fb.setCell(text1.length + i * 3 + 2, 0, " ", defaultFg, bgColor) + } + + // Row 2: Indexed foregrounds on default background + const text2 = "Indexed FGs on default BG: " + for (let i = 0; i < text2.length; i++) { + fb.drawText(text2[i], i, 2, RGBA.fromIndex(7), defaultBg, TextAttributes.NONE) + } + + const fgIndices = [196, 46, 21, 226, 201, 51, 208, 231] + for (let i = 0; i < fgIndices.length; i++) { + const fgColor = RGBA.fromIndex(fgIndices[i]) + fb.setCell(text2.length + i * 3, 2, " ", fgColor, defaultBg) + fb.setCell(text2.length + i * 3 + 1, 2, "X", fgColor, defaultBg) + fb.setCell(text2.length + i * 3 + 2, 2, " ", fgColor, defaultBg) + } +} + +function drawFullPalette(buffer: FrameBufferRenderable): void { + const fb = buffer.frameBuffer + const defaultBg = RGBA.defaultBackground() + fb.clear(defaultBg) + + // Draw all 256 colors in a 16x16 grid + for (let i = 0; i < 256; i++) { + const row = Math.floor(i / 16) + const col = i % 16 + const x = col * 4 + const y = row + + const bgColor = RGBA.fromIndex(i) + + // Draw 4-char wide block + for (let dx = 0; dx < 4; dx++) { + fb.setCell(x + dx, y, " ", RGBA.fromInts(0, 0, 0), bgColor) + } + } +} + +function drawGradients(buffer: FrameBufferRenderable): void { + const fb = buffer.frameBuffer + const defaultBg = RGBA.defaultBackground() + fb.clear(defaultBg) + + // Red gradient (using color cube red axis) + const redLabel = "Red: " + for (let i = 0; i < redLabel.length; i++) { + fb.drawText(redLabel[i], i, 0, RGBA.fromIndex(7), defaultBg, TextAttributes.NONE) + } + for (let r = 0; r < 6; r++) { + const index = 16 + r * 36 // Red axis (g=0, b=0) + const bgColor = RGBA.fromIndex(index) + for (let dx = 0; dx < 10; dx++) { + fb.setCell(redLabel.length + r * 10 + dx, 0, " ", RGBA.fromInts(0, 0, 0), bgColor) + } + } + + // Green gradient + const greenLabel = "Green: " + for (let i = 0; i < greenLabel.length; i++) { + fb.drawText(greenLabel[i], i, 2, RGBA.fromIndex(7), defaultBg, TextAttributes.NONE) + } + for (let g = 0; g < 6; g++) { + const index = 16 + g * 6 // Green axis (r=0, b=0) + const bgColor = RGBA.fromIndex(index) + for (let dx = 0; dx < 10; dx++) { + fb.setCell(greenLabel.length + g * 10 + dx, 2, " ", RGBA.fromInts(0, 0, 0), bgColor) + } + } + + // Blue gradient + const blueLabel = "Blue: " + for (let i = 0; i < blueLabel.length; i++) { + fb.drawText(blueLabel[i], i, 4, RGBA.fromIndex(7), defaultBg, TextAttributes.NONE) + } + for (let b = 0; b < 6; b++) { + const index = 16 + b // Blue axis (r=0, g=0) + const bgColor = RGBA.fromIndex(index) + for (let dx = 0; dx < 10; dx++) { + fb.setCell(blueLabel.length + b * 10 + dx, 4, " ", RGBA.fromInts(0, 0, 0), bgColor) + } + } + + // Grayscale gradient + const grayLabel = "Gray: " + for (let i = 0; i < grayLabel.length; i++) { + fb.drawText(grayLabel[i], i, 6, RGBA.fromIndex(7), defaultBg, TextAttributes.NONE) + } + for (let g = 0; g < 24; g++) { + const index = 232 + g + const bgColor = RGBA.fromIndex(index) + for (let dx = 0; dx < 2; dx++) { + fb.setCell(grayLabel.length + g * 2 + dx, 6, " ", RGBA.fromInts(0, 0, 0), bgColor) + } + } +} + +export function destroy(renderer: CliRenderer): void { + if (keyboardHandler) { + renderer.keyInput.off("keypress", keyboardHandler) + keyboardHandler = null + } + + if (scrollBox) { + renderer.root.remove("main-container") + scrollBox = null + } + + contentContainer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + run(renderer) + setupCommonDemoKeys(renderer) +} diff --git a/packages/core/src/lib/RGBA.test.ts b/packages/core/src/lib/RGBA.test.ts index 3fe35b119..e8cd6b3db 100644 --- a/packages/core/src/lib/RGBA.test.ts +++ b/packages/core/src/lib/RGBA.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test" -import { RGBA, hexToRgb, rgbToHex, hsvToRgb, parseColor } from "./RGBA" +import { RGBA, hexToRgb, rgbToHex, hsvToRgb, parseBgColor, parseColor } from "./RGBA" describe("RGBA class", () => { describe("constructor", () => { @@ -146,6 +146,39 @@ describe("RGBA class", () => { }) }) + describe("fromIndex", () => { + test("creates indexed color with correct type/index", () => { + const rgba = RGBA.fromIndex(46) + expect(rgba.isIndexed()).toBe(true) + expect(rgba.index).toBe(46) + expect(rgba.a).toBe(1) + }) + + test("clamps invalid indices to 0", () => { + const rgbaNeg = RGBA.fromIndex(-1) + expect(rgbaNeg.isIndexed()).toBe(true) + expect(rgbaNeg.index).toBe(0) + + const rgbaBig = RGBA.fromIndex(999) + expect(rgbaBig.isIndexed()).toBe(true) + expect(rgbaBig.index).toBe(0) + }) + }) + + describe("default colors", () => { + test("defaultForeground is COLOR_TYPE_DEFAULT and opaque", () => { + const fg = RGBA.defaultForeground() + expect(fg.isDefault()).toBe(true) + expect(fg.a).toBe(1) + }) + + test("defaultBackground is COLOR_TYPE_DEFAULT and transparent", () => { + const bg = RGBA.defaultBackground() + expect(bg.isDefault()).toBe(true) + expect(bg.a).toBe(0) + }) + }) + describe("toInts", () => { test("converts float values to integers (0-255)", () => { const rgba = RGBA.fromValues(1.0, 0.5, 0.25, 0.75) @@ -726,6 +759,35 @@ describe("parseColor", () => { expect(rgba.a).toBe(0) }) + test("parses default keyword as fg by default and bg with parseBgColor", () => { + const fg = parseColor("default") + const bg = parseBgColor("default") + + expect(fg.isDefault()).toBe(true) + expect(bg.isDefault()).toBe(true) + + // Default background uses alpha 0 to avoid forcing a color + expect(bg.a).toBe(0) + }) + + test("parses ansi:N format as indexed color", () => { + const rgba = parseColor("ansi:196") + expect(rgba.isIndexed()).toBe(true) + expect(rgba.index).toBe(196) + }) + + test("parses numeric indexed color", () => { + const rgba = parseColor(46) + expect(rgba.isIndexed()).toBe(true) + expect(rgba.index).toBe(46) + }) + + test("parses { index } object as indexed color", () => { + const rgba = parseColor({ index: 226 }) + expect(rgba.isIndexed()).toBe(true) + expect(rgba.index).toBe(226) + }) + test("parses TRANSPARENT (uppercase)", () => { const rgba = parseColor("TRANSPARENT") expect(rgba.r).toBe(0) diff --git a/packages/core/src/lib/RGBA.ts b/packages/core/src/lib/RGBA.ts index 1dae109a9..8e5829079 100644 --- a/packages/core/src/lib/RGBA.ts +++ b/packages/core/src/lib/RGBA.ts @@ -1,8 +1,16 @@ +export const COLOR_TYPE_RGB = 0 +export const COLOR_TYPE_INDEXED = 1 +export const COLOR_TYPE_DEFAULT = 2 + export class RGBA { buffer: Float32Array + colorType: number + index: number - constructor(buffer: Float32Array) { + constructor(buffer: Float32Array, colorType: number = COLOR_TYPE_RGB, index: number = 0) { this.buffer = buffer + this.colorType = colorType + this.index = index } static fromArray(array: Float32Array) { @@ -21,6 +29,35 @@ export class RGBA { return hexToRgb(hex) } + static fromIndex(index: number): RGBA { + if (index < 0 || index > 255) { + console.warn(`Invalid indexed color: ${index}, must be 0-255, defaulting to 0`) + index = 0 + } + const rgb = indexToApproximateRgb(index) + return new RGBA(new Float32Array([rgb.r, rgb.g, rgb.b, 1.0]), COLOR_TYPE_INDEXED, index) + } + + static defaultForeground(): RGBA { + return new RGBA(new Float32Array([1.0, 1.0, 1.0, 1.0]), COLOR_TYPE_DEFAULT, 0) + } + + static defaultBackground(): RGBA { + return new RGBA(new Float32Array([0.0, 0.0, 0.0, 0.0]), COLOR_TYPE_DEFAULT, 0) + } + + isIndexed(): boolean { + return this.colorType === COLOR_TYPE_INDEXED + } + + isDefault(): boolean { + return this.colorType === COLOR_TYPE_DEFAULT + } + + isRgb(): boolean { + return this.colorType === COLOR_TYPE_RGB + } + toInts(): [number, number, number, number] { return [Math.round(this.r * 255), Math.round(this.g * 255), Math.round(this.b * 255), Math.round(this.a * 255)] } @@ -62,6 +99,12 @@ export class RGBA { } toString() { + if (this.colorType === COLOR_TYPE_INDEXED) { + return `ansi:${this.index}` + } + if (this.colorType === COLOR_TYPE_DEFAULT) { + return "default" + } return `rgba(${this.r.toFixed(2)}, ${this.g.toFixed(2)}, ${this.b.toFixed(2)}, ${this.a.toFixed(2)})` } @@ -71,7 +114,53 @@ export class RGBA { } } -export type ColorInput = string | RGBA +export type IndexedColor = { index: number } +export type ColorInput = string | RGBA | IndexedColor | number + +const STANDARD_COLORS: Array<{ r: number; g: number; b: number }> = [ + { r: 0, g: 0, b: 0 }, // 0: Black + { r: 0.5, g: 0, b: 0 }, // 1: Maroon + { r: 0, g: 0.5, b: 0 }, // 2: Green + { r: 0.5, g: 0.5, b: 0 }, // 3: Olive + { r: 0, g: 0, b: 0.5 }, // 4: Navy + { r: 0.5, g: 0, b: 0.5 }, // 5: Purple + { r: 0, g: 0.5, b: 0.5 }, // 6: Teal + { r: 0.75, g: 0.75, b: 0.75 }, // 7: Silver + { r: 0.5, g: 0.5, b: 0.5 }, // 8: Gray + { r: 1, g: 0, b: 0 }, // 9: Red + { r: 0, g: 1, b: 0 }, // 10: Lime + { r: 1, g: 1, b: 0 }, // 11: Yellow + { r: 0, g: 0, b: 1 }, // 12: Blue + { r: 1, g: 0, b: 1 }, // 13: Fuchsia + { r: 0, g: 1, b: 1 }, // 14: Aqua + { r: 1, g: 1, b: 1 }, // 15: White +] + +export function indexToApproximateRgb(index: number): { r: number; g: number; b: number } { + if (index < 0 || index > 255) { + return { r: 1, g: 0, b: 1 } // Magenta for invalid + } + + // Standard colors (0-15) + if (index < 16) { + return STANDARD_COLORS[index] + } + + // 216 color cube (16-231) + if (index < 232) { + const cubeIndex = index - 16 + const b = cubeIndex % 6 + const g = Math.floor(cubeIndex / 6) % 6 + const r = Math.floor(cubeIndex / 36) + // 6x6x6 cube: values are 0, 95, 135, 175, 215, 255 -> normalized + const levels = [0, 0.373, 0.529, 0.686, 0.843, 1.0] + return { r: levels[r], g: levels[g], b: levels[b] } + } + + // Grayscale (232-255) + const gray = (index - 232) * (1.0 / 23) + return { r: gray, g: gray, b: gray } +} export function hexToRgb(hex: string): RGBA { hex = hex.replace(/^#/, "") @@ -186,7 +275,29 @@ const CSS_COLOR_NAMES: Record = { brightwhite: "#FFFFFF", } +/** + * Parse a color input into an RGBA instance. + * + * Note: "default" is treated as the terminal's default FOREGROUND here. + * Use `parseBgColor()` when parsing backgrounds. + */ export function parseColor(color: ColorInput): RGBA { + // Handle RGBA instances directly + if (color instanceof RGBA) { + return color + } + + // Handle numeric indexed colors (0-255) + if (typeof color === "number") { + return RGBA.fromIndex(color) + } + + // Handle IndexedColor objects + if (typeof color === "object" && "index" in color) { + return RGBA.fromIndex(color.index) + } + + // Handle string colors if (typeof color === "string") { const lowerColor = color.toLowerCase() @@ -194,11 +305,51 @@ export function parseColor(color: ColorInput): RGBA { return RGBA.fromValues(0, 0, 0, 0) } + if (lowerColor === "default") { + return RGBA.defaultForeground() + } + + // Handle "ansi:N" format for indexed colors + if (lowerColor.startsWith("ansi:")) { + const indexStr = lowerColor.slice(5) + const index = parseInt(indexStr, 10) + if (!isNaN(index)) { + return RGBA.fromIndex(index) + } + console.warn(`Invalid ansi color format: ${color}, defaulting to magenta`) + return RGBA.fromValues(1, 0, 1, 1) + } + if (CSS_COLOR_NAMES[lowerColor]) { return hexToRgb(CSS_COLOR_NAMES[lowerColor]) } return hexToRgb(color) } - return color + + console.warn(`Unknown color type: ${typeof color}, defaulting to magenta`) + return RGBA.fromValues(1, 0, 1, 1) +} + +/** + * Parse a foreground color. + * + * This exists to keep `parseColor` stateless while still allowing callers + * to intentionally select "default" foreground semantics. + */ +export function parseFgColor(color: ColorInput): RGBA { + return parseColor(color) +} + +/** + * Parse a background color. + * + * Unlike `parseColor`, the "default" keyword maps to the terminal's default + * BACKGROUND. + */ +export function parseBgColor(color: ColorInput): RGBA { + if (typeof color === "string" && color.toLowerCase() === "default") { + return RGBA.defaultBackground() + } + return parseColor(color) } diff --git a/packages/core/src/lib/styled-text.ts b/packages/core/src/lib/styled-text.ts index a65527d0a..31cbebc11 100644 --- a/packages/core/src/lib/styled-text.ts +++ b/packages/core/src/lib/styled-text.ts @@ -1,7 +1,7 @@ import type { TextRenderable } from "../renderables/Text" import type { TextBuffer, TextChunk } from "../text-buffer" import { createTextAttributes } from "../utils" -import { parseColor, type ColorInput } from "./RGBA" +import { parseBgColor, parseFgColor, type ColorInput } from "./RGBA" const BrandedStyledText: unique symbol = Symbol.for("@opentui/core/StyledText") @@ -47,8 +47,8 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk { if (typeof input === "object" && "__isChunk" in input) { const existingChunk = input as TextChunk - const fg = style.fg ? parseColor(style.fg) : existingChunk.fg - const bg = style.bg ? parseColor(style.bg) : existingChunk.bg + const fg = style.fg ? parseFgColor(style.fg) : existingChunk.fg + const bg = style.bg ? parseBgColor(style.bg) : existingChunk.bg const newAttrs = createTextAttributes(style) const mergedAttrs = existingChunk.attributes ? existingChunk.attributes | newAttrs : newAttrs @@ -63,8 +63,8 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk { } } else { const plainTextStr = String(input) - const fg = style.fg ? parseColor(style.fg) : undefined - const bg = style.bg ? parseColor(style.bg) : undefined + const fg = style.fg ? parseFgColor(style.fg) : undefined + const bg = style.bg ? parseBgColor(style.bg) : undefined const attributes = createTextAttributes(style) return { diff --git a/packages/core/src/lib/terminal-palette.test.ts b/packages/core/src/lib/terminal-palette.test.ts index 5d4985c34..ab23c8062 100644 --- a/packages/core/src/lib/terminal-palette.test.ts +++ b/packages/core/src/lib/terminal-palette.test.ts @@ -234,6 +234,76 @@ test("TerminalPalette returns null for colors that don't respond", async () => { expect(result.palette.some((color: string | null) => color === null)).toBe(true) }) +test("TerminalPalette setPaletteColor writes OSC 4", () => { + const stdin = new MockStream() as any + const stdout = new MockStream() as any + + const writes: string[] = [] + const palette = new TerminalPalette(stdin, stdout, (data) => { + writes.push(Buffer.isBuffer(data) ? data.toString() : data) + return true + }) + + palette.setPaletteColor(1, "#abc") + expect(writes.join("")) + // normalizeHex expands #abc to aabbcc + .toBe("\x1b]4;1;#aabbcc\x07") +}) + +test("TerminalPalette setForeground writes OSC 10", () => { + const stdin = new MockStream() as any + const stdout = new MockStream() as any + + const writes: string[] = [] + const palette = new TerminalPalette(stdin, stdout, (data) => { + writes.push(Buffer.isBuffer(data) ? data.toString() : data) + return true + }) + + palette.setForeground("00ff00") + expect(writes.join("")).toBe("\x1b]10;#00ff00\x07") +}) + +test("TerminalPalette setBackground writes OSC 11", () => { + const stdin = new MockStream() as any + const stdout = new MockStream() as any + + const writes: string[] = [] + const palette = new TerminalPalette(stdin, stdout, (data) => { + writes.push(Buffer.isBuffer(data) ? data.toString() : data) + return true + }) + + palette.setBackground("#112233") + expect(writes.join("")).toBe("\x1b]11;#112233\x07") +}) + +test("TerminalPalette reset* methods write correct OSC", () => { + const stdin = new MockStream() as any + const stdout = new MockStream() as any + + const writes: string[] = [] + const palette = new TerminalPalette(stdin, stdout, (data) => { + writes.push(Buffer.isBuffer(data) ? data.toString() : data) + return true + }) + + palette.resetPaletteColor(5) + palette.resetForeground() + palette.resetBackground() + + expect(writes).toEqual(["\x1b]104;5\x07", "\x1b]110\x07", "\x1b]111\x07"]) +}) + +test("TerminalPalette normalizeHex throws on invalid hex", () => { + const stdin = new MockStream() as any + const stdout = new MockStream() as any + const palette = new TerminalPalette(stdin, stdout) + + expect(() => palette.setForeground("#zzzzzz")).toThrow() + expect(() => palette.setForeground("#1234")).toThrow() +}) + test("TerminalPalette handles response split across chunks", async () => { const stdin = new MockStream() as any const stdout = new MockStream() as any diff --git a/packages/core/src/lib/terminal-palette.ts b/packages/core/src/lib/terminal-palette.ts index f7ee0d3be..c6a4dff84 100644 --- a/packages/core/src/lib/terminal-palette.ts +++ b/packages/core/src/lib/terminal-palette.ts @@ -30,6 +30,12 @@ export interface TerminalPaletteDetector { detect(options?: GetPaletteOptions): Promise detectOSCSupport(timeoutMs?: number): Promise cleanup(): void + setPaletteColor(index: number, hex: string): void + setForeground(hex: string): void + setBackground(hex: string): void + resetPaletteColor(index: number): void + resetForeground(): void + resetBackground(): void } function scaleComponent(comp: string): string { @@ -334,6 +340,57 @@ export class TerminalPalette implements TerminalPaletteDetector { highlightForeground: specialColors[19], } } + + private normalizeHex(hex: string): string { + // Validate hex format: #RGB, #RRGGBB, RGB, or RRGGBB + let h = hex.startsWith("#") ? hex.slice(1) : hex + + // Validate length (3 or 6 after removing #) + if (h.length !== 3 && h.length !== 6) { + throw new Error(`Invalid hex color format: "${hex}". Expected #RGB, #RRGGBB, RGB, or RRGGBB`) + } + + // Validate all characters are valid hex + if (!/^[0-9a-fA-F]+$/.test(h)) { + throw new Error(`Invalid hex color: "${hex}". Contains non-hex characters`) + } + + // Expand 3-character hex to 6-character format + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + } + + return h.toLowerCase() + } + + setPaletteColor(index: number, hex: string): void { + if (index < 0 || index > 255) return + const h = this.normalizeHex(hex) + this.writeOsc(`\x1b]4;${index};#${h}\x07`) + } + + setForeground(hex: string): void { + const h = this.normalizeHex(hex) + this.writeOsc(`\x1b]10;#${h}\x07`) + } + + setBackground(hex: string): void { + const h = this.normalizeHex(hex) + this.writeOsc(`\x1b]11;#${h}\x07`) + } + + resetPaletteColor(index: number): void { + if (index < 0 || index > 255) return + this.writeOsc(`\x1b]104;${index}\x07`) + } + + resetForeground(): void { + this.writeOsc(`\x1b]110\x07`) + } + + resetBackground(): void { + this.writeOsc(`\x1b]111\x07`) + } } export function createTerminalPalette( diff --git a/packages/core/src/renderables/Box.ts b/packages/core/src/renderables/Box.ts index 8507418af..30403d71a 100644 --- a/packages/core/src/renderables/Box.ts +++ b/packages/core/src/renderables/Box.ts @@ -10,7 +10,7 @@ import { getBorderSides, parseBorderStyle, } from "../lib" -import { type ColorInput, RGBA, parseColor } from "../lib/RGBA" +import { parseBgColor, type ColorInput, RGBA, parseColor } from "../lib/RGBA" import { isValidPercentage } from "../lib/renderable.validations" import type { RenderContext } from "../types" @@ -66,11 +66,12 @@ export class BoxRenderable extends Renderable { constructor(ctx: RenderContext, options: BoxOptions) { super(ctx, options) + this._backgroundColor = parseBgColor(options.backgroundColor || this._defaultOptions.backgroundColor) + if (options.focusable === true) { this._focusable = true } - this._backgroundColor = parseColor(options.backgroundColor || this._defaultOptions.backgroundColor) this._border = options.border ?? this._defaultOptions.border if ( !options.border && @@ -124,7 +125,7 @@ export class BoxRenderable extends Renderable { } public set backgroundColor(value: RGBA | string | undefined) { - const newColor = parseColor(value ?? this._defaultOptions.backgroundColor) + const newColor = parseBgColor(value ?? this._defaultOptions.backgroundColor) if (this._backgroundColor !== newColor) { this._backgroundColor = newColor this.requestRender() diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index 35b2e7e78..45d8a03ab 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -2,7 +2,7 @@ import { Renderable, type RenderableOptions } from "../Renderable" import { convertGlobalToLocalSelection, Selection, type LocalSelectionBounds } from "../lib/selection" import { EditBuffer, type LogicalCursor } from "../edit-buffer" import { EditorView, type VisualCursor } from "../editor-view" -import { RGBA, parseColor } from "../lib/RGBA" +import { RGBA, parseColor, type ColorInput } from "../lib/RGBA" import type { RenderContext, Highlight, CursorStyleOptions, LineInfoProvider, LineInfo } from "../types" import type { OptimizedBuffer } from "../buffer" import { MeasureMode } from "yoga-layout" @@ -18,21 +18,21 @@ export interface ContentChangeEvent { } export interface EditBufferOptions extends RenderableOptions { - textColor?: string | RGBA - backgroundColor?: string | RGBA - selectionBg?: string | RGBA - selectionFg?: string | RGBA + textColor?: ColorInput + backgroundColor?: ColorInput + selectionBg?: ColorInput + selectionFg?: ColorInput selectable?: boolean attributes?: number wrapMode?: "none" | "char" | "word" scrollMargin?: number scrollSpeed?: number showCursor?: boolean - cursorColor?: string | RGBA + cursorColor?: ColorInput cursorStyle?: CursorStyleOptions syntaxStyle?: SyntaxStyle tabIndicator?: string | number - tabIndicatorColor?: string | RGBA + tabIndicatorColor?: ColorInput onCursorChange?: (event: CursorChangeEvent) => void onContentChange?: (event: ContentChangeEvent) => void } diff --git a/packages/core/src/renderables/TextBufferRenderable.ts b/packages/core/src/renderables/TextBufferRenderable.ts index a4243a029..94e548a56 100644 --- a/packages/core/src/renderables/TextBufferRenderable.ts +++ b/packages/core/src/renderables/TextBufferRenderable.ts @@ -2,7 +2,7 @@ import { Renderable, type RenderableOptions } from "../Renderable" import { convertGlobalToLocalSelection, Selection, type LocalSelectionBounds } from "../lib/selection" import { TextBuffer, type TextChunk } from "../text-buffer" import { TextBufferView } from "../text-buffer-view" -import { RGBA, parseColor } from "../lib/RGBA" +import { parseBgColor, parseFgColor, RGBA } from "../lib/RGBA" import { type RenderContext, type LineInfoProvider } from "../types" import type { OptimizedBuffer } from "../buffer" import { MeasureMode } from "yoga-layout" @@ -57,16 +57,16 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf constructor(ctx: RenderContext, options: TextBufferOptions) { super(ctx, options) - this._defaultFg = parseColor(options.fg ?? this._defaultOptions.fg) - this._defaultBg = parseColor(options.bg ?? this._defaultOptions.bg) + this._defaultFg = parseFgColor(options.fg ?? this._defaultOptions.fg) + this._defaultBg = parseBgColor(options.bg ?? this._defaultOptions.bg) this._defaultAttributes = options.attributes ?? this._defaultOptions.attributes - this._selectionBg = options.selectionBg ? parseColor(options.selectionBg) : this._defaultOptions.selectionBg - this._selectionFg = options.selectionFg ? parseColor(options.selectionFg) : this._defaultOptions.selectionFg + this._selectionBg = options.selectionBg ? parseBgColor(options.selectionBg) : this._defaultOptions.selectionBg + this._selectionFg = options.selectionFg ? parseFgColor(options.selectionFg) : this._defaultOptions.selectionFg this.selectable = options.selectable ?? this._defaultOptions.selectable this._wrapMode = options.wrapMode ?? this._defaultOptions.wrapMode this._tabIndicator = options.tabIndicator ?? this._defaultOptions.tabIndicator this._tabIndicatorColor = options.tabIndicatorColor - ? parseColor(options.tabIndicatorColor) + ? parseFgColor(options.tabIndicatorColor) : this._defaultOptions.tabIndicatorColor this._truncate = options.truncate ?? this._defaultOptions.truncate @@ -205,7 +205,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } set fg(value: RGBA | string | undefined) { - const newColor = parseColor(value ?? this._defaultOptions.fg) + const newColor = parseFgColor(value ?? this._defaultOptions.fg) if (this._defaultFg !== newColor) { this._defaultFg = newColor this.textBuffer.setDefaultFg(this._defaultFg) @@ -219,7 +219,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } set selectionBg(value: RGBA | string | undefined) { - const newColor = value ? parseColor(value) : this._defaultOptions.selectionBg + const newColor = value ? parseBgColor(value) : this._defaultOptions.selectionBg if (this._selectionBg !== newColor) { this._selectionBg = newColor if (this.lastLocalSelection) { @@ -234,7 +234,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } set selectionFg(value: RGBA | string | undefined) { - const newColor = value ? parseColor(value) : this._defaultOptions.selectionFg + const newColor = value ? parseFgColor(value) : this._defaultOptions.selectionFg if (this._selectionFg !== newColor) { this._selectionFg = newColor if (this.lastLocalSelection) { @@ -249,7 +249,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } set bg(value: RGBA | string | undefined) { - const newColor = parseColor(value ?? this._defaultOptions.bg) + const newColor = parseBgColor(value ?? this._defaultOptions.bg) if (this._defaultBg !== newColor) { this._defaultBg = newColor this.textBuffer.setDefaultBg(this._defaultBg) @@ -307,7 +307,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } set tabIndicatorColor(value: RGBA | string | undefined) { - const newColor = value ? parseColor(value) : undefined + const newColor = value ? parseFgColor(value) : undefined if (this._tabIndicatorColor !== newColor) { this._tabIndicatorColor = newColor if (newColor !== undefined) { diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 296b45f9d..248dc9fb6 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -7,7 +7,7 @@ import { type ViewportBounds, type WidthMethod, } from "./types" -import { RGBA, parseColor, type ColorInput } from "./lib/RGBA" +import { parseBgColor, RGBA, type ColorInput } from "./lib/RGBA" import type { Pointer } from "bun:ffi" import { OptimizedBuffer } from "./buffer" import { resolveRenderLib, type RenderLib } from "./zig" @@ -1441,7 +1441,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } public setBackgroundColor(color: ColorInput): void { - const parsedColor = parseColor(color) + const parsedColor = parseBgColor(color) this.lib.setBackgroundColor(this.rendererPtr, parsedColor as RGBA) this.backgroundColor = parsedColor as RGBA this.nextRenderBuffer.clear(parsedColor as RGBA) diff --git a/packages/core/src/syntax-style.ts b/packages/core/src/syntax-style.ts index e82304a4c..3d456e4ed 100644 --- a/packages/core/src/syntax-style.ts +++ b/packages/core/src/syntax-style.ts @@ -1,4 +1,4 @@ -import { RGBA, parseColor, type ColorInput } from "./lib/RGBA" +import { parseBgColor, parseFgColor, RGBA, type ColorInput } from "./lib/RGBA" import { resolveRenderLib, type RenderLib } from "./zig" import { type Pointer } from "bun:ffi" import { createTextAttributes } from "./utils" @@ -12,6 +12,15 @@ export interface StyleDefinition { dim?: boolean } +export interface StyleDefinitionInput { + fg?: ColorInput + bg?: ColorInput + bold?: boolean + italic?: boolean + underline?: boolean + dim?: boolean +} + export interface MergedStyle { fg?: RGBA bg?: RGBA @@ -37,10 +46,10 @@ export function convertThemeToStyles(theme: ThemeTokenStyle[]): Record): SyntaxStyle { + static fromStyles(styles: Record): SyntaxStyle { const style = SyntaxStyle.create() for (const [name, styleDef] of Object.entries(styles)) { @@ -109,7 +118,18 @@ export class SyntaxStyle { if (this._destroyed) throw new Error("NativeSyntaxStyle is destroyed") } - public registerStyle(name: string, style: StyleDefinition): number { + private normalizeStyleDefinition(style: StyleDefinitionInput): StyleDefinition { + const fg = style.fg ? (style.fg instanceof RGBA ? style.fg : parseFgColor(style.fg)) : undefined + const bg = style.bg ? (style.bg instanceof RGBA ? style.bg : parseBgColor(style.bg)) : undefined + + return { + ...style, + fg, + bg, + } + } + + public registerStyle(name: string, style: StyleDefinitionInput): number { this.guard() const attributes = createTextAttributes({ @@ -119,10 +139,17 @@ export class SyntaxStyle { dim: style.dim, }) - const id = this.lib.syntaxStyleRegister(this.stylePtr, name, style.fg || null, style.bg || null, attributes) + const normalizedStyle = this.normalizeStyleDefinition(style) + const id = this.lib.syntaxStyleRegister( + this.stylePtr, + name, + normalizedStyle.fg ?? null, + normalizedStyle.bg ?? null, + attributes, + ) this.nameCache.set(name, id) - this.styleDefs.set(name, style) + this.styleDefs.set(name, normalizedStyle) return id } diff --git a/packages/core/src/zig-structs.ts b/packages/core/src/zig-structs.ts index 9c16229cb..63641d197 100644 --- a/packages/core/src/zig-structs.ts +++ b/packages/core/src/zig-structs.ts @@ -27,6 +27,12 @@ export const StyledChunkStruct = defineStruct([ }, ], ["attributes", "u32", { optional: true }], + // Preserve terminal palette semantics for indexed/default colors. + // These are kept separate from the RGBA buffer (which is only used for RGB / approximate values). + ["fg_color_type", "u8", { optional: true, default: 0 }], + ["bg_color_type", "u8", { optional: true, default: 0 }], + ["fg_index", "u8", { optional: true, default: 0 }], + ["bg_index", "u8", { optional: true, default: 0 }], ]) export const HighlightStruct = defineStruct([ diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index a81dd0e85..d01a950c5 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -106,7 +106,7 @@ function getOpenTUILib(libPath?: string) { returns: "void", }, setBackgroundColor: { - args: ["ptr", "ptr"], + args: ["ptr", "ptr", "u8", "u8"], returns: "void", }, setRenderOffset: { @@ -213,6 +213,14 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "u32", "u32", "u32", "ptr", "ptr", "u32"], returns: "void", }, + bufferSetCellWithColorType: { + args: ["ptr", "u32", "u32", "u32", "ptr", "ptr", "u32", "u8", "u8", "u8", "u8"], + returns: "void", + }, + bufferSetCellWithAlphaBlendingAndColorType: { + args: ["ptr", "u32", "u32", "u32", "ptr", "ptr", "u32", "u8", "u8", "u8", "u8"], + returns: "void", + }, bufferFillRect: { args: ["ptr", "u32", "u32", "u32", "u32", "ptr"], returns: "void", @@ -304,7 +312,7 @@ function getOpenTUILib(libPath?: string) { returns: "void", }, bufferDrawBox: { - args: ["ptr", "i32", "i32", "u32", "u32", "ptr", "u32", "ptr", "ptr", "ptr", "u32"], + args: ["ptr", "i32", "i32", "u32", "u32", "ptr", "u32", "ptr", "u8", "u8", "ptr", "u8", "u8", "ptr", "u32"], returns: "void", }, bufferPushScissorRect: { @@ -978,7 +986,7 @@ function getOpenTUILib(libPath?: string) { returns: "void", }, syntaxStyleRegister: { - args: ["ptr", "ptr", "usize", "ptr", "ptr", "u8"], + args: ["ptr", "ptr", "usize", "ptr", "ptr", "u32", "u8", "u8", "u8", "u8"], returns: "u32", }, syntaxStyleResolveByName: { @@ -1013,6 +1021,11 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "u32", "u32", "u32", "ptr", "ptr", "u32"], returns: "void", }, + + bufferClearWithColorType: { + args: ["ptr", "ptr", "u8", "u8"], + returns: "void", + }, }) if (env.OTUI_DEBUG_FFI || env.OTUI_TRACE_FFI) { @@ -1298,6 +1311,7 @@ export interface RenderLib { getBufferWidth: (buffer: Pointer) => number getBufferHeight: (buffer: Pointer) => number bufferClear: (buffer: Pointer, color: RGBA) => void + bufferClearWithColorType: (buffer: Pointer, color: RGBA) => void bufferGetCharPtr: (buffer: Pointer) => Pointer bufferGetFgPtr: (buffer: Pointer) => Pointer bufferGetBgPtr: (buffer: Pointer) => Pointer @@ -1334,6 +1348,24 @@ export interface RenderLib { bgColor: RGBA, attributes?: number, ) => void + bufferSetCellWithColorType: ( + buffer: Pointer, + x: number, + y: number, + char: string, + color: RGBA, + bgColor: RGBA, + attributes?: number, + ) => void + bufferSetCellWithAlphaBlendingAndColorType: ( + buffer: Pointer, + x: number, + y: number, + char: string, + color: RGBA, + bgColor: RGBA, + attributes?: number, + ) => void bufferFillRect: (buffer: Pointer, x: number, y: number, width: number, height: number, color: RGBA) => void bufferDrawSuperSampleBuffer: ( buffer: Pointer, @@ -1707,6 +1739,36 @@ class FFIRenderLib implements RenderLib { this.setupEventBus() } + private clampU8(value: unknown): number { + const n = typeof value === "number" ? value : Number(value) + if (!Number.isFinite(n)) return 0 + return Math.max(0, Math.min(255, Math.trunc(n))) + } + + private getColorMeta(color?: RGBA | null): { colorType: number; index: number } { + if (!color) { + return { colorType: 0, index: 0 } + } + + const isColorInstance = + typeof (color as RGBA).isDefault === "function" && typeof (color as RGBA).isIndexed === "function" + + if (!isColorInstance) { + return { colorType: 0, index: 0 } + } + + // Don't trust raw `color.colorType` values crossing public APIs; normalize before calling Zig. + if (color.isDefault()) { + return { colorType: 2, index: 0 } + } + + if (color.isIndexed()) { + return { colorType: 1, index: this.clampU8(color.index) } + } + + return { colorType: 0, index: 0 } + } + private setupLogging() { if (this.logCallbackWrapper) { return @@ -1835,7 +1897,8 @@ class FFIRenderLib implements RenderLib { } public setBackgroundColor(renderer: Pointer, color: RGBA) { - this.opentui.symbols.setBackgroundColor(renderer, color.buffer) + const { colorType, index } = this.getColorMeta(color) + this.opentui.symbols.setBackgroundColor(renderer, color.buffer, colorType, index) } public setRenderOffset(renderer: Pointer, offset: number) { @@ -1948,6 +2011,11 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.bufferClear(buffer, color.buffer) } + public bufferClearWithColorType(buffer: Pointer, color: RGBA): void { + const { colorType, index } = this.getColorMeta(color) + this.opentui.symbols.bufferClearWithColorType(buffer, color.buffer, colorType, index) + } + public bufferDrawText( buffer: Pointer, text: string, @@ -1997,6 +2065,72 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.bufferSetCell(buffer, x, y, charPtr, fg, bg, attributes ?? 0) } + public bufferSetCellWithColorType( + buffer: Pointer, + x: number, + y: number, + char: string, + color: RGBA, + bgColor: RGBA, + attributes?: number, + ) { + const charPtr = char.codePointAt(0) ?? " ".codePointAt(0)! + const bg = bgColor.buffer + const fg = color.buffer + + const fg_color_type = color.colorType ?? 0 // Default to rgb (0) + const bg_color_type = bgColor.colorType ?? 0 // Default to rgb (0) + const fg_index = color.index ?? 0 + const bg_index = bgColor.index ?? 0 + + this.opentui.symbols.bufferSetCellWithColorType( + buffer, + x, + y, + charPtr, + fg, + bg, + attributes ?? 0, + fg_color_type, + bg_color_type, + fg_index, + bg_index, + ) + } + + public bufferSetCellWithAlphaBlendingAndColorType( + buffer: Pointer, + x: number, + y: number, + char: string, + color: RGBA, + bgColor: RGBA, + attributes?: number, + ) { + const charPtr = char.codePointAt(0) ?? " ".codePointAt(0)! + const bg = bgColor.buffer + const fg = color.buffer + + const fg_color_type = color.colorType ?? 0 // Default to rgb (0) + const bg_color_type = bgColor.colorType ?? 0 // Default to rgb (0) + const fg_index = color.index ?? 0 + const bg_index = bgColor.index ?? 0 + + this.opentui.symbols.bufferSetCellWithAlphaBlendingAndColorType( + buffer, + x, + y, + charPtr, + fg, + bg, + attributes ?? 0, + fg_color_type, + bg_color_type, + fg_index, + bg_index, + ) + } + public bufferFillRect(buffer: Pointer, x: number, y: number, width: number, height: number, color: RGBA) { const bg = color.buffer this.opentui.symbols.bufferFillRect(buffer, x, y, width, height, bg) @@ -2103,6 +2237,9 @@ class FFIRenderLib implements RenderLib { const titleLen = title ? titleBytes!.length : 0 const titlePtr = title ? titleBytes : null + const borderMeta = this.getColorMeta(borderColor) + const backgroundMeta = this.getColorMeta(backgroundColor) + this.opentui.symbols.bufferDrawBox( buffer, x, @@ -2112,7 +2249,11 @@ class FFIRenderLib implements RenderLib { borderChars, packedOptions, borderColor.buffer, + borderMeta.colorType, + borderMeta.index, backgroundColor.buffer, + backgroundMeta.colorType, + backgroundMeta.index, titlePtr, titleLen, ) @@ -2478,9 +2619,22 @@ class FFIRenderLib implements RenderLib { return chunk }) - const chunksBuffer = StyledChunkStruct.packList(processedChunks) + // Preserve indexed/default colors across the FFI boundary + const chunksWithColorType = processedChunks.map((chunk) => { + const fgMeta = this.getColorMeta(chunk.fg ?? null) + const bgMeta = this.getColorMeta(chunk.bg ?? null) + return { + ...chunk, + fg_color_type: fgMeta.colorType, + bg_color_type: bgMeta.colorType, + fg_index: fgMeta.index, + bg_index: bgMeta.index, + } + }) + + const chunksBuffer = StyledChunkStruct.packList(chunksWithColorType) - this.opentui.symbols.textBufferSetStyledText(buffer, ptr(chunksBuffer), processedChunks.length) + this.opentui.symbols.textBufferSetStyledText(buffer, ptr(chunksBuffer), chunksWithColorType.length) } public textBufferGetLineCount(buffer: Pointer): number { @@ -3453,7 +3607,22 @@ class FFIRenderLib implements RenderLib { const nameBytes = this.encoder.encode(name) const fgPtr = fg ? fg.buffer : null const bgPtr = bg ? bg.buffer : null - return this.opentui.symbols.syntaxStyleRegister(style, nameBytes, nameBytes.length, fgPtr, bgPtr, attributes) + + const fgMeta = this.getColorMeta(fg) + const bgMeta = this.getColorMeta(bg) + + return this.opentui.symbols.syntaxStyleRegister( + style, + nameBytes, + nameBytes.length, + fgPtr, + bgPtr, + attributes, + fgMeta.colorType, + bgMeta.colorType, + fgMeta.index, + bgMeta.index, + ) } public syntaxStyleResolveByName(style: Pointer, name: string): number | null { @@ -3477,8 +3646,20 @@ class FFIRenderLib implements RenderLib { return } - const chunksBuffer = StyledChunkStruct.packList(nonEmptyChunks) - this.opentui.symbols.editorViewSetPlaceholderStyledText(view, ptr(chunksBuffer), nonEmptyChunks.length) + const chunksWithColorType = nonEmptyChunks.map((chunk) => { + const fgMeta = this.getColorMeta(chunk.fg ?? null) + const bgMeta = this.getColorMeta(chunk.bg ?? null) + return { + ...chunk, + fg_color_type: fgMeta.colorType, + bg_color_type: bgMeta.colorType, + fg_index: fgMeta.index, + bg_index: bgMeta.index, + } + }) + + const chunksBuffer = StyledChunkStruct.packList(chunksWithColorType) + this.opentui.symbols.editorViewSetPlaceholderStyledText(view, ptr(chunksBuffer), chunksWithColorType.length) } public editorViewSetTabIndicator(view: Pointer, indicator: number): void { diff --git a/packages/core/src/zig/ansi.zig b/packages/core/src/zig/ansi.zig index f6f0a28b1..f3d21e67e 100644 --- a/packages/core/src/zig/ansi.zig +++ b/packages/core/src/zig/ansi.zig @@ -3,6 +3,58 @@ const Allocator = std.mem.Allocator; pub const RGBA = [4]f32; +pub const ColorType = enum(u8) { + rgb = 0, + indexed = 1, + default = 2, +}; + +const STANDARD_COLORS = [16]RGBA{ + .{ 0.0, 0.0, 0.0, 1.0 }, // 0: Black + .{ 0.5, 0.0, 0.0, 1.0 }, // 1: Maroon + .{ 0.0, 0.5, 0.0, 1.0 }, // 2: Green + .{ 0.5, 0.5, 0.0, 1.0 }, // 3: Olive + .{ 0.0, 0.0, 0.5, 1.0 }, // 4: Navy + .{ 0.5, 0.0, 0.5, 1.0 }, // 5: Purple + .{ 0.0, 0.5, 0.5, 1.0 }, // 6: Teal + .{ 0.75, 0.75, 0.75, 1.0 }, // 7: Silver + .{ 0.5, 0.5, 0.5, 1.0 }, // 8: Gray + .{ 1.0, 0.0, 0.0, 1.0 }, // 9: Red + .{ 0.0, 1.0, 0.0, 1.0 }, // 10: Lime + .{ 1.0, 1.0, 0.0, 1.0 }, // 11: Yellow + .{ 0.0, 0.0, 1.0, 1.0 }, // 12: Blue + .{ 1.0, 0.0, 1.0, 1.0 }, // 13: Fuchsia + .{ 0.0, 1.0, 1.0, 1.0 }, // 14: Aqua + .{ 1.0, 1.0, 1.0, 1.0 }, // 15: White +}; + +const COLOR_CUBE_LEVELS = [6]f32{ 0.0, 0.373, 0.529, 0.686, 0.843, 1.0 }; + +pub fn indexToApproximateRgba(index: u8) RGBA { + // Standard colors (0-15) + if (index < 16) { + return STANDARD_COLORS[index]; + } + + // 216 color cube (16-231) + if (index < 232) { + const cube_index = index - 16; + const b_idx = cube_index % 6; + const g_idx = (cube_index / 6) % 6; + const r_idx = cube_index / 36; + return .{ + COLOR_CUBE_LEVELS[r_idx], + COLOR_CUBE_LEVELS[g_idx], + COLOR_CUBE_LEVELS[b_idx], + 1.0, + }; + } + + // Grayscale (232-255) + const gray_level: f32 = @as(f32, @floatFromInt(index - 232)) / 23.0; + return .{ gray_level, gray_level, gray_level, 1.0 }; +} + pub const AnsiError = error{ InvalidFormat, WriteFailed, @@ -32,6 +84,22 @@ pub const ANSI = struct { writer.print("\x1b[48;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; } + pub fn fgIndexedColorOutput(writer: anytype, index: u8) AnsiError!void { + writer.print("\x1b[38;5;{d}m", .{index}) catch return AnsiError.WriteFailed; + } + + pub fn bgIndexedColorOutput(writer: anytype, index: u8) AnsiError!void { + writer.print("\x1b[48;5;{d}m", .{index}) catch return AnsiError.WriteFailed; + } + + pub fn fgDefaultOutput(writer: anytype) AnsiError!void { + writer.writeAll("\x1b[39m") catch return AnsiError.WriteFailed; + } + + pub fn bgDefaultOutput(writer: anytype) AnsiError!void { + writer.writeAll("\x1b[49m") catch return AnsiError.WriteFailed; + } + // Text attribute constants pub const bold = "\x1b[1m"; pub const dim = "\x1b[2m"; diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index 60b6f9454..1a47ae565 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -90,6 +90,10 @@ pub const Cell = struct { fg: RGBA, bg: RGBA, attributes: u32, + fg_color_type: ansi.ColorType = .rgb, + bg_color_type: ansi.ColorType = .rgb, + fg_index: u8 = 0, + bg_index: u8 = 0, }; fn isRGBAWithAlpha(color: RGBA) bool { @@ -146,6 +150,10 @@ pub const OptimizedBuffer = struct { fg: []RGBA, bg: []RGBA, attributes: []u32, + fg_color_type: []ansi.ColorType, + bg_color_type: []ansi.ColorType, + fg_index: []u8, + bg_index: []u8, }, width: u32, height: u32, @@ -197,6 +205,10 @@ pub const OptimizedBuffer = struct { .fg = allocator.alloc(RGBA, size) catch return BufferError.OutOfMemory, .bg = allocator.alloc(RGBA, size) catch return BufferError.OutOfMemory, .attributes = allocator.alloc(u32, size) catch return BufferError.OutOfMemory, + .fg_color_type = allocator.alloc(ansi.ColorType, size) catch return BufferError.OutOfMemory, + .bg_color_type = allocator.alloc(ansi.ColorType, size) catch return BufferError.OutOfMemory, + .fg_index = allocator.alloc(u8, size) catch return BufferError.OutOfMemory, + .bg_index = allocator.alloc(u8, size) catch return BufferError.OutOfMemory, }, .width = width, .height = height, @@ -216,6 +228,10 @@ pub const OptimizedBuffer = struct { @memset(self.buffer.fg, .{ 0.0, 0.0, 0.0, 0.0 }); @memset(self.buffer.bg, .{ 0.0, 0.0, 0.0, 0.0 }); @memset(self.buffer.attributes, 0); + @memset(self.buffer.fg_color_type, .rgb); + @memset(self.buffer.bg_color_type, .rgb); + @memset(self.buffer.fg_index, 0); + @memset(self.buffer.bg_index, 0); return self; } @@ -245,6 +261,10 @@ pub const OptimizedBuffer = struct { self.allocator.free(self.buffer.fg); self.allocator.free(self.buffer.bg); self.allocator.free(self.buffer.attributes); + self.allocator.free(self.buffer.fg_color_type); + self.allocator.free(self.buffer.bg_color_type); + self.allocator.free(self.buffer.fg_index); + self.allocator.free(self.buffer.bg_index); self.allocator.free(self.id); self.allocator.destroy(self); } @@ -369,6 +389,10 @@ pub const OptimizedBuffer = struct { self.buffer.fg = self.allocator.realloc(self.buffer.fg, size) catch return BufferError.OutOfMemory; self.buffer.bg = self.allocator.realloc(self.buffer.bg, size) catch return BufferError.OutOfMemory; self.buffer.attributes = self.allocator.realloc(self.buffer.attributes, size) catch return BufferError.OutOfMemory; + self.buffer.fg_color_type = self.allocator.realloc(self.buffer.fg_color_type, size) catch return BufferError.OutOfMemory; + self.buffer.bg_color_type = self.allocator.realloc(self.buffer.bg_color_type, size) catch return BufferError.OutOfMemory; + self.buffer.fg_index = self.allocator.realloc(self.buffer.fg_index, size) catch return BufferError.OutOfMemory; + self.buffer.bg_index = self.allocator.realloc(self.buffer.bg_index, size) catch return BufferError.OutOfMemory; self.width = width; self.height = height; @@ -390,6 +414,10 @@ pub const OptimizedBuffer = struct { } pub fn clear(self: *OptimizedBuffer, bg: RGBA, char: ?u32) !void { + try self.clearWithColorType(bg, char, .rgb, 0); + } + + pub fn clearWithColorType(self: *OptimizedBuffer, bg: RGBA, char: ?u32, bg_color_type: ansi.ColorType, bg_index: u8) !void { const cellChar = char orelse DEFAULT_SPACE_CHAR; self.link_tracker.clear(); self.grapheme_tracker.clear(); @@ -397,6 +425,10 @@ pub const OptimizedBuffer = struct { @memset(self.buffer.attributes, 0); @memset(self.buffer.fg, .{ 1.0, 1.0, 1.0, 1.0 }); @memset(self.buffer.bg, bg); + @memset(self.buffer.fg_color_type, .rgb); + @memset(self.buffer.bg_color_type, bg_color_type); + @memset(self.buffer.fg_index, 0); + @memset(self.buffer.bg_index, bg_index); } pub fn setRaw(self: *OptimizedBuffer, x: u32, y: u32, cell: Cell) void { @@ -412,6 +444,10 @@ pub const OptimizedBuffer = struct { self.buffer.fg[index] = cell.fg; self.buffer.bg[index] = cell.bg; self.buffer.attributes[index] = cell.attributes; + self.buffer.fg_color_type[index] = cell.fg_color_type; + self.buffer.bg_color_type[index] = cell.bg_color_type; + self.buffer.fg_index[index] = cell.fg_index; + self.buffer.bg_index[index] = cell.bg_index; if (prev_link_id != 0 and prev_link_id != new_link_id) { self.link_tracker.removeCellRef(prev_link_id); @@ -454,6 +490,10 @@ pub const OptimizedBuffer = struct { @memset(self.buffer.char[span_start .. span_start + span_len], @intCast(DEFAULT_SPACE_CHAR)); @memset(self.buffer.attributes[span_start .. span_start + span_len], 0); + @memset(self.buffer.fg_color_type[span_start .. span_start + span_len], .rgb); + @memset(self.buffer.bg_color_type[span_start .. span_start + span_len], .rgb); + @memset(self.buffer.fg_index[span_start .. span_start + span_len], 0); + @memset(self.buffer.bg_index[span_start .. span_start + span_len], 0); } if (gp.isGraphemeChar(cell.char)) { @@ -473,6 +513,10 @@ pub const OptimizedBuffer = struct { @memset(self.buffer.attributes[index..end_of_line], cell.attributes); @memset(self.buffer.fg[index..end_of_line], cell.fg); @memset(self.buffer.bg[index..end_of_line], cell.bg); + @memset(self.buffer.fg_color_type[index..end_of_line], cell.fg_color_type); + @memset(self.buffer.bg_color_type[index..end_of_line], cell.bg_color_type); + @memset(self.buffer.fg_index[index..end_of_line], cell.fg_index); + @memset(self.buffer.bg_index[index..end_of_line], cell.bg_index); const new_link_id = ansi.TextAttributes.getLinkId(cell.attributes); if (new_link_id != 0) { const cells_written = end_of_line - index; @@ -488,6 +532,10 @@ pub const OptimizedBuffer = struct { self.buffer.fg[index] = cell.fg; self.buffer.bg[index] = cell.bg; self.buffer.attributes[index] = cell.attributes; + self.buffer.fg_color_type[index] = cell.fg_color_type; + self.buffer.bg_color_type[index] = cell.bg_color_type; + self.buffer.fg_index[index] = cell.fg_index; + self.buffer.bg_index[index] = cell.bg_index; const id: u32 = gp.graphemeIdFromChar(cell.char); self.grapheme_tracker.add(id); @@ -515,6 +563,10 @@ pub const OptimizedBuffer = struct { @memset(self.buffer.fg[index + 1 .. index + 1 + max_right], cell.fg); @memset(self.buffer.bg[index + 1 .. index + 1 + max_right], cell.bg); @memset(self.buffer.attributes[index + 1 .. index + 1 + max_right], cell.attributes); + @memset(self.buffer.fg_color_type[index + 1 .. index + 1 + max_right], cell.fg_color_type); + @memset(self.buffer.bg_color_type[index + 1 .. index + 1 + max_right], cell.bg_color_type); + @memset(self.buffer.fg_index[index + 1 .. index + 1 + max_right], cell.fg_index); + @memset(self.buffer.bg_index[index + 1 .. index + 1 + max_right], cell.bg_index); var k: u32 = 1; while (k <= max_right) : (k += 1) { const cont = gp.packContinuation(k, max_right - k, id); @@ -530,6 +582,10 @@ pub const OptimizedBuffer = struct { self.buffer.fg[index] = cell.fg; self.buffer.bg[index] = cell.bg; self.buffer.attributes[index] = cell.attributes; + self.buffer.fg_color_type[index] = cell.fg_color_type; + self.buffer.bg_color_type[index] = cell.bg_color_type; + self.buffer.fg_index[index] = cell.fg_index; + self.buffer.bg_index[index] = cell.bg_index; const new_link_id = ansi.TextAttributes.getLinkId(cell.attributes); if (prev_link_id != 0 and prev_link_id != new_link_id) { @@ -550,6 +606,10 @@ pub const OptimizedBuffer = struct { .fg = self.buffer.fg[index], .bg = self.buffer.bg[index], .attributes = self.buffer.attributes[index], + .fg_color_type = self.buffer.fg_color_type[index], + .bg_color_type = self.buffer.bg_color_type[index], + .fg_index = self.buffer.fg_index[index], + .bg_index = self.buffer.bg_index[index], }; } @@ -669,10 +729,24 @@ pub const OptimizedBuffer = struct { const finalChar = if (preserveChar) destCell.char else overlayCell.char; var finalFg: RGBA = undefined; + var finalFgColorType: ansi.ColorType = undefined; + var finalFgIndex: u8 = undefined; + if (preserveChar) { finalFg = blendColors(overlayCell.bg, destCell.fg); + // Blending always produces RGB result + finalFgColorType = .rgb; + finalFgIndex = 0; } else { finalFg = if (hasFgAlpha) blendColors(overlayCell.fg, destCell.bg) else overlayCell.fg; + // If fg was blended, result is RGB; otherwise preserve overlay's color type + if (hasFgAlpha) { + finalFgColorType = .rgb; + finalFgIndex = 0; + } else { + finalFgColorType = overlayCell.fg_color_type; + finalFgIndex = overlayCell.fg_index; + } } // When preserving char, preserve its base attributes but NOT its link @@ -689,11 +763,19 @@ pub const OptimizedBuffer = struct { // When overlay background is fully transparent, preserve destination background alpha const finalBgAlpha = if (overlayCell.bg[3] == 0.0) destCell.bg[3] else overlayCell.bg[3]; + // Background color type: if blended, becomes RGB + const finalBgColorType: ansi.ColorType = if (hasBgAlpha) .rgb else overlayCell.bg_color_type; + const finalBgIndex: u8 = if (hasBgAlpha) 0 else overlayCell.bg_index; + return Cell{ .char = finalChar, .fg = finalFg, .bg = .{ blendedBgRgb[0], blendedBgRgb[1], blendedBgRgb[2], finalBgAlpha }, .attributes = finalAttributes, + .fg_color_type = finalFgColorType, + .bg_color_type = finalBgColorType, + .fg_index = finalFgIndex, + .bg_index = finalBgIndex, }; } @@ -769,6 +851,60 @@ pub const OptimizedBuffer = struct { } } + pub fn setCellWithColorType( + self: *OptimizedBuffer, + x: u32, + y: u32, + char: u32, + fg: RGBA, + bg: RGBA, + attributes: u32, + fg_color_type: ansi.ColorType, + bg_color_type: ansi.ColorType, + fg_index: u8, + bg_index: u8, + ) !void { + if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; + + const opacity = self.getCurrentOpacity(); + const cell = Cell{ + .char = char, + .fg = fg, + .bg = bg, + .attributes = attributes, + .fg_color_type = fg_color_type, + .bg_color_type = bg_color_type, + .fg_index = fg_index, + .bg_index = bg_index, + }; + + if (isFullyOpaque(opacity, fg, bg)) { + self.set(x, y, cell); + return; + } + + const effectiveFg = RGBA{ fg[0], fg[1], fg[2], fg[3] * opacity }; + const effectiveBg = RGBA{ bg[0], bg[1], bg[2], bg[3] * opacity }; + + const overlayCell = Cell{ + .char = char, + .fg = effectiveFg, + .bg = effectiveBg, + .attributes = attributes, + .fg_color_type = fg_color_type, + .bg_color_type = bg_color_type, + .fg_index = fg_index, + .bg_index = bg_index, + }; + + if (self.get(x, y)) |destCell| { + const blendedCell = blendCells(overlayCell, destCell); + self.set(x, y, blendedCell); + } else { + self.set(x, y, overlayCell); + } + } + pub fn drawChar( self: *OptimizedBuffer, char: u32, @@ -799,6 +935,19 @@ pub const OptimizedBuffer = struct { width: u32, height: u32, bg: RGBA, + ) !void { + try self.fillRectWithColorType(x, y, width, height, bg, .rgb, 0); + } + + pub fn fillRectWithColorType( + self: *OptimizedBuffer, + x: u32, + y: u32, + width: u32, + height: u32, + bg: RGBA, + bg_color_type: ansi.ColorType, + bg_index: u8, ) !void { if (self.width == 0 or self.height == 0 or width == 0 or height == 0) return; if (x >= self.width or y >= self.height) return; @@ -831,7 +980,18 @@ pub const OptimizedBuffer = struct { while (fillY <= clippedEndY) : (fillY += 1) { var fillX = clippedStartX; while (fillX <= clippedEndX) : (fillX += 1) { - try self.setCellWithAlphaBlending(fillX, fillY, DEFAULT_SPACE_CHAR, .{ 1.0, 1.0, 1.0, 1.0 }, bg, 0); + try self.setCellWithColorType( + fillX, + fillY, + DEFAULT_SPACE_CHAR, + .{ 1.0, 1.0, 1.0, 1.0 }, + bg, + 0, + .rgb, + bg_color_type, + 0, + bg_index, + ); } } } else { @@ -845,11 +1005,15 @@ pub const OptimizedBuffer = struct { const rowSliceFg = self.buffer.fg[rowStartIndex .. rowStartIndex + rowWidth]; const rowSliceBg = self.buffer.bg[rowStartIndex .. rowStartIndex + rowWidth]; const rowSliceAttrs = self.buffer.attributes[rowStartIndex .. rowStartIndex + rowWidth]; + const rowSliceBgType = self.buffer.bg_color_type[rowStartIndex .. rowStartIndex + rowWidth]; + const rowSliceBgIndex = self.buffer.bg_index[rowStartIndex .. rowStartIndex + rowWidth]; @memset(rowSliceChar, @intCast(DEFAULT_SPACE_CHAR)); @memset(rowSliceFg, .{ 1.0, 1.0, 1.0, 1.0 }); @memset(rowSliceBg, bg); @memset(rowSliceAttrs, 0); + @memset(rowSliceBgType, bg_color_type); + @memset(rowSliceBgIndex, bg_index); } } } @@ -1035,6 +1199,10 @@ pub const OptimizedBuffer = struct { @memcpy(self.buffer.fg[destRowStart .. destRowStart + actualCopyWidth], frameBuffer.buffer.fg[srcRowStart .. srcRowStart + actualCopyWidth]); @memcpy(self.buffer.bg[destRowStart .. destRowStart + actualCopyWidth], frameBuffer.buffer.bg[srcRowStart .. srcRowStart + actualCopyWidth]); @memcpy(self.buffer.attributes[destRowStart .. destRowStart + actualCopyWidth], frameBuffer.buffer.attributes[srcRowStart .. srcRowStart + actualCopyWidth]); + @memcpy(self.buffer.fg_color_type[destRowStart .. destRowStart + actualCopyWidth], frameBuffer.buffer.fg_color_type[srcRowStart .. srcRowStart + actualCopyWidth]); + @memcpy(self.buffer.bg_color_type[destRowStart .. destRowStart + actualCopyWidth], frameBuffer.buffer.bg_color_type[srcRowStart .. srcRowStart + actualCopyWidth]); + @memcpy(self.buffer.fg_index[destRowStart .. destRowStart + actualCopyWidth], frameBuffer.buffer.fg_index[srcRowStart .. srcRowStart + actualCopyWidth]); + @memcpy(self.buffer.bg_index[destRowStart .. destRowStart + actualCopyWidth], frameBuffer.buffer.bg_index[srcRowStart .. srcRowStart + actualCopyWidth]); } return; } @@ -1154,9 +1322,20 @@ pub const OptimizedBuffer = struct { var lineFg = text_buffer.default_fg orelse RGBA{ 1.0, 1.0, 1.0, 1.0 }; var lineBg = text_buffer.default_bg orelse RGBA{ 0.0, 0.0, 0.0, 0.0 }; var lineAttributes = text_buffer.default_attributes orelse 0; + + // TextBuffer defaults currently don't encode color type; treat them as RGB. + var lineFgColorType: ansi.ColorType = .rgb; + var lineBgColorType: ansi.ColorType = .rgb; + var lineFgIndex: u8 = 0; + var lineBgIndex: u8 = 0; + const defaultFg = lineFg; const defaultBg = lineBg; const defaultAttributes = lineAttributes; + const defaultFgColorType = lineFgColorType; + const defaultBgColorType = lineBgColorType; + const defaultFgIndex = lineFgIndex; + const defaultBgIndex = lineBgIndex; // Find the span that contains the starting render position (col_offset + horizontal_offset) const start_col = col_offset + horizontal_offset; @@ -1173,8 +1352,16 @@ pub const OptimizedBuffer = struct { if (span_idx < spans.len and spans[span_idx].col <= start_col and spans[span_idx].style_id != 0) { if (text_buffer.getSyntaxStyle()) |style| { if (style.resolveById(spans[span_idx].style_id)) |resolved_style| { - if (resolved_style.fg) |fg| lineFg = fg; - if (resolved_style.bg) |bg| lineBg = bg; + if (resolved_style.fg) |fg| { + lineFg = fg; + lineFgColorType = resolved_style.fg_color_type; + lineFgIndex = resolved_style.fg_index; + } + if (resolved_style.bg) |bg| { + lineBg = bg; + lineBgColorType = resolved_style.bg_color_type; + lineBgIndex = resolved_style.bg_index; + } lineAttributes |= resolved_style.attributes; } } @@ -1302,12 +1489,24 @@ pub const OptimizedBuffer = struct { lineFg = defaultFg; lineBg = defaultBg; lineAttributes = defaultAttributes; + lineFgColorType = defaultFgColorType; + lineBgColorType = defaultBgColorType; + lineFgIndex = defaultFgIndex; + lineBgIndex = defaultBgIndex; if (text_buffer.getSyntaxStyle()) |style| { if (new_span.style_id != 0) { if (style.resolveById(new_span.style_id)) |resolved_style| { - if (resolved_style.fg) |fg| lineFg = fg; - if (resolved_style.bg) |bg| lineBg = bg; + if (resolved_style.fg) |fg| { + lineFg = fg; + lineFgColorType = resolved_style.fg_color_type; + lineFgIndex = resolved_style.fg_index; + } + if (resolved_style.bg) |bg| { + lineBg = bg; + lineBgColorType = resolved_style.bg_color_type; + lineBgIndex = resolved_style.bg_index; + } lineAttributes |= resolved_style.attributes; } } @@ -1323,12 +1522,20 @@ pub const OptimizedBuffer = struct { lineFg = defaultFg; lineBg = defaultBg; lineAttributes = defaultAttributes; + lineFgColorType = defaultFgColorType; + lineBgColorType = defaultBgColorType; + lineFgIndex = defaultFgIndex; + lineBgIndex = defaultBgIndex; } else if (column_offset_in_line >= vline.ellipsis_pos + ellipsis_width) { const suffix_col_pos = vline.truncation_suffix_start + (column_offset_in_line - vline.ellipsis_pos - ellipsis_width); if (spans.len == 0) { lineFg = defaultFg; lineBg = defaultBg; lineAttributes = defaultAttributes; + lineFgColorType = defaultFgColorType; + lineBgColorType = defaultBgColorType; + lineFgIndex = defaultFgIndex; + lineBgIndex = defaultBgIndex; next_change_col = std.math.maxInt(u32); } else { var suffix_span_idx: usize = 0; @@ -1345,8 +1552,16 @@ pub const OptimizedBuffer = struct { if (text_buffer.getSyntaxStyle()) |style| { if (active_span.style_id != 0) { if (style.resolveById(active_span.style_id)) |resolved_style| { - if (resolved_style.fg) |fg| lineFg = fg; - if (resolved_style.bg) |bg| lineBg = bg; + if (resolved_style.fg) |fg| { + lineFg = fg; + lineFgColorType = resolved_style.fg_color_type; + lineFgIndex = resolved_style.fg_index; + } + if (resolved_style.bg) |bg| { + lineBg = bg; + lineBgColorType = resolved_style.bg_color_type; + lineBgIndex = resolved_style.bg_index; + } lineAttributes |= resolved_style.attributes; } } @@ -1360,6 +1575,11 @@ pub const OptimizedBuffer = struct { var finalBg = lineBg; const finalAttributes = lineAttributes; + var finalFgColorType = lineFgColorType; + var finalBgColorType = lineBgColorType; + var finalFgIndex = lineFgIndex; + var finalBgIndex = lineBgIndex; + var cell_idx: u32 = 0; while (cell_idx < g_width) : (cell_idx += 1) { if (view.getSelection()) |sel| { @@ -1367,13 +1587,31 @@ pub const OptimizedBuffer = struct { if (isSelected) { if (sel.bgColor) |selBg| { finalBg = selBg; + finalBgColorType = .rgb; + finalBgIndex = 0; if (sel.fgColor) |selFg| { finalFg = selFg; + finalFgColorType = .rgb; + finalFgIndex = 0; } } else { const temp = lineFg; - finalFg = if (lineBg[3] > 0) lineBg else RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const tempType = lineFgColorType; + const tempIndex = lineFgIndex; + + if (lineBg[3] > 0) { + finalFg = lineBg; + finalFgColorType = lineBgColorType; + finalFgIndex = lineBgIndex; + } else { + finalFg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + finalFgColorType = .rgb; + finalFgIndex = 0; + } + finalBg = temp; + finalBgColorType = tempType; + finalBgIndex = tempIndex; } break; } @@ -1388,12 +1626,24 @@ pub const OptimizedBuffer = struct { var drawFg = finalFg; var drawBg = finalBg; + var drawFgColorType = finalFgColorType; + var drawBgColorType = finalBgColorType; + var drawFgIndex = finalFgIndex; + var drawBgIndex = finalBgIndex; const drawAttributes = finalAttributes; if (drawAttributes & (1 << 5) != 0) { const temp = drawFg; drawFg = drawBg; drawBg = temp; + + const tempType = drawFgColorType; + drawFgColorType = drawBgColorType; + drawBgColorType = tempType; + + const tempIndex = drawFgIndex; + drawFgIndex = drawBgIndex; + drawBgIndex = tempIndex; } if (grapheme_bytes.len == 1 and grapheme_bytes[0] == '\t') { @@ -1407,13 +1657,20 @@ pub const OptimizedBuffer = struct { const char = if (tab_col == 0 and tab_indicator != null) tab_indicator.? else DEFAULT_SPACE_CHAR; const fg = if (tab_col == 0 and tab_indicator_color != null) tab_indicator_color.? else drawFg; - try self.setCellWithAlphaBlending( + const fgType: ansi.ColorType = if (tab_col == 0 and tab_indicator_color != null) .rgb else drawFgColorType; + const fgIndex: u8 = if (tab_col == 0 and tab_indicator_color != null) 0 else drawFgIndex; + + try self.setCellWithColorType( @intCast(currentX + @as(i32, @intCast(tab_col))), @intCast(currentY), char, fg, drawBg, drawAttributes, + fgType, + drawBgColorType, + fgIndex, + drawBgIndex, ); } } else { @@ -1431,13 +1688,17 @@ pub const OptimizedBuffer = struct { encoded_char = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, g_width); } - try self.setCellWithAlphaBlending( + try self.setCellWithColorType( @intCast(currentX), @intCast(currentY), encoded_char, drawFg, drawBg, drawAttributes, + drawFgColorType, + drawBgColorType, + drawFgIndex, + drawBgIndex, ); } @@ -1485,6 +1746,10 @@ pub const OptimizedBuffer = struct { borderSides: BorderSides, borderColor: RGBA, backgroundColor: RGBA, + borderColorType: ansi.ColorType, + backgroundColorType: ansi.ColorType, + borderIndex: u8, + backgroundIndex: u8, shouldFill: bool, title: ?[]const u8, titleAlignment: u8, // 0=left, 1=center, 2=right @@ -1540,7 +1805,15 @@ pub const OptimizedBuffer = struct { if (!borderSides.top and !borderSides.right and !borderSides.bottom and !borderSides.left) { const fillWidth = @as(u32, @intCast(endX - startX + 1)); const fillHeight = @as(u32, @intCast(endY - startY + 1)); - try self.fillRect(@intCast(startX), @intCast(startY), fillWidth, fillHeight, backgroundColor); + try self.fillRectWithColorType( + @intCast(startX), + @intCast(startY), + fillWidth, + fillHeight, + backgroundColor, + backgroundColorType, + backgroundIndex, + ); } else { const innerStartX = startX + if (borderSides.left and isAtActualLeft) @as(i32, 1) else @as(i32, 0); const innerStartY = startY + if (borderSides.top and isAtActualTop) @as(i32, 1) else @as(i32, 0); @@ -1550,7 +1823,15 @@ pub const OptimizedBuffer = struct { if (innerEndX >= innerStartX and innerEndY >= innerStartY) { const fillWidth = @as(u32, @intCast(innerEndX - innerStartX + 1)); const fillHeight = @as(u32, @intCast(innerEndY - innerStartY + 1)); - try self.fillRect(@intCast(innerStartX), @intCast(innerStartY), fillWidth, fillHeight, backgroundColor); + try self.fillRectWithColorType( + @intCast(innerStartX), + @intCast(innerStartY), + fillWidth, + fillHeight, + backgroundColor, + backgroundColorType, + backgroundIndex, + ); } } } @@ -1584,7 +1865,18 @@ pub const OptimizedBuffer = struct { char = if (borderSides.right) borderChars[@intFromEnum(BorderCharIndex.topRight)] else borderChars[@intFromEnum(BorderCharIndex.horizontal)]; } - try self.setCellWithAlphaBlending(@intCast(drawX), @intCast(startY), char, borderColor, backgroundColor, 0); + try self.setCellWithColorType( + @intCast(drawX), + @intCast(startY), + char, + borderColor, + backgroundColor, + 0, + borderColorType, + backgroundColorType, + borderIndex, + backgroundIndex, + ); } } } @@ -1603,7 +1895,18 @@ pub const OptimizedBuffer = struct { char = if (borderSides.right) borderChars[@intFromEnum(BorderCharIndex.bottomRight)] else borderChars[@intFromEnum(BorderCharIndex.horizontal)]; } - try self.setCellWithAlphaBlending(@intCast(drawX), @intCast(endY), char, borderColor, backgroundColor, 0); + try self.setCellWithColorType( + @intCast(drawX), + @intCast(endY), + char, + borderColor, + backgroundColor, + 0, + borderColorType, + backgroundColorType, + borderIndex, + backgroundIndex, + ); } } } @@ -1618,12 +1921,34 @@ pub const OptimizedBuffer = struct { while (drawY <= verticalEndY) : (drawY += 1) { // Left border if (borderSides.left and isAtActualLeft and startX >= 0 and startX < @as(i32, @intCast(self.width))) { - try self.setCellWithAlphaBlending(@intCast(startX), @intCast(drawY), borderChars[@intFromEnum(BorderCharIndex.vertical)], borderColor, backgroundColor, 0); + try self.setCellWithColorType( + @intCast(startX), + @intCast(drawY), + borderChars[@intFromEnum(BorderCharIndex.vertical)], + borderColor, + backgroundColor, + 0, + borderColorType, + backgroundColorType, + borderIndex, + backgroundIndex, + ); } // Right border if (borderSides.right and isAtActualRight and endX >= 0 and endX < @as(i32, @intCast(self.width))) { - try self.setCellWithAlphaBlending(@intCast(endX), @intCast(drawY), borderChars[@intFromEnum(BorderCharIndex.vertical)], borderColor, backgroundColor, 0); + try self.setCellWithColorType( + @intCast(endX), + @intCast(drawY), + borderChars[@intFromEnum(BorderCharIndex.vertical)], + borderColor, + backgroundColor, + 0, + borderColorType, + backgroundColorType, + borderIndex, + backgroundIndex, + ); } } } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index a7d4d0fb3..b35ae958e 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -61,8 +61,8 @@ export fn destroyRenderer(rendererPtr: *renderer.CliRenderer) void { rendererPtr.destroy(); } -export fn setBackgroundColor(rendererPtr: *renderer.CliRenderer, color: [*]const f32) void { - rendererPtr.setBackgroundColor(utils.f32PtrToRGBA(color)); +export fn setBackgroundColor(rendererPtr: *renderer.CliRenderer, color: [*]const f32, color_type: u8, color_index: u8) void { + rendererPtr.setBackgroundColor(utils.f32PtrToRGBA(color), @enumFromInt(color_type), color_index); } export fn setRenderOffset(rendererPtr: *renderer.CliRenderer, offset: u32) void { @@ -298,6 +298,10 @@ export fn bufferClear(bufferPtr: *buffer.OptimizedBuffer, bg: [*]const f32) void bufferPtr.clear(utils.f32PtrToRGBA(bg), null) catch {}; } +export fn bufferClearWithColorType(bufferPtr: *buffer.OptimizedBuffer, bg: [*]const f32, bg_color_type: u8, bg_index: u8) void { + bufferPtr.clearWithColorType(utils.f32PtrToRGBA(bg), null, @enumFromInt(bg_color_type), bg_index) catch {}; +} + export fn bufferGetCharPtr(bufferPtr: *buffer.OptimizedBuffer) [*]u32 { return bufferPtr.getCharPtr(); } @@ -362,6 +366,42 @@ export fn bufferSetCell(bufferPtr: *buffer.OptimizedBuffer, x: u32, y: u32, char bufferPtr.set(x, y, cell); } +export fn bufferSetCellWithColorType( + bufferPtr: *buffer.OptimizedBuffer, + x: u32, + y: u32, + char: u32, + fg: [*]const f32, + bg: [*]const f32, + attributes: u32, + fg_color_type: u8, + bg_color_type: u8, + fg_index: u8, + bg_index: u8, +) void { + const rgbaFg = utils.f32PtrToRGBA(fg); + const rgbaBg = utils.f32PtrToRGBA(bg); + bufferPtr.setCellWithColorType(x, y, char, rgbaFg, rgbaBg, attributes, @enumFromInt(fg_color_type), @enumFromInt(bg_color_type), fg_index, bg_index) catch {}; +} + +export fn bufferSetCellWithAlphaBlendingAndColorType( + bufferPtr: *buffer.OptimizedBuffer, + x: u32, + y: u32, + char: u32, + fg: [*]const f32, + bg: [*]const f32, + attributes: u32, + fg_color_type: u8, + bg_color_type: u8, + fg_index: u8, + bg_index: u8, +) void { + const rgbaFg = utils.f32PtrToRGBA(fg); + const rgbaBg = utils.f32PtrToRGBA(bg); + bufferPtr.setCellWithColorType(x, y, char, rgbaFg, rgbaBg, attributes, @enumFromInt(fg_color_type), @enumFromInt(bg_color_type), fg_index, bg_index) catch {}; +} + export fn bufferFillRect(bufferPtr: *buffer.OptimizedBuffer, x: u32, y: u32, width: u32, height: u32, bg: [*]const f32) void { const rgbaBg = utils.f32PtrToRGBA(bg); bufferPtr.fillRect(x, y, width, height, rgbaBg) catch {}; @@ -447,7 +487,11 @@ export fn bufferDrawBox( borderChars: [*]const u32, packedOptions: u32, borderColor: [*]const f32, + borderColorType: u8, + borderIndex: u8, backgroundColor: [*]const f32, + backgroundColorType: u8, + backgroundIndex: u8, title: ?[*]const u8, titleLen: u32, ) void { @@ -472,6 +516,10 @@ export fn bufferDrawBox( borderSides, utils.f32PtrToRGBA(borderColor), utils.f32PtrToRGBA(backgroundColor), + @enumFromInt(borderColorType), + @enumFromInt(backgroundColorType), + borderIndex, + backgroundIndex, shouldFill, titleSlice, titleAlignment, @@ -1451,11 +1499,31 @@ export fn destroySyntaxStyle(style: *syntax_style.SyntaxStyle) void { style.deinit(); } -export fn syntaxStyleRegister(style: *syntax_style.SyntaxStyle, namePtr: [*]const u8, nameLen: usize, fg: ?[*]const f32, bg: ?[*]const f32, attributes: u32) u32 { +export fn syntaxStyleRegister( + style: *syntax_style.SyntaxStyle, + namePtr: [*]const u8, + nameLen: usize, + fg: ?[*]const f32, + bg: ?[*]const f32, + attributes: u32, + fg_color_type: u8, + bg_color_type: u8, + fg_index: u8, + bg_index: u8, +) u32 { const name = namePtr[0..nameLen]; const fgColor = if (fg) |fgPtr| utils.f32PtrToRGBA(fgPtr) else null; const bgColor = if (bg) |bgPtr| utils.f32PtrToRGBA(bgPtr) else null; - return style.registerStyle(name, fgColor, bgColor, attributes) catch 0; + return style.registerStyleWithColorInfo( + name, + fgColor, + bgColor, + attributes, + @enumFromInt(fg_color_type), + @enumFromInt(bg_color_type), + fg_index, + bg_index, + ) catch 0; } export fn syntaxStyleResolveByName(style: *syntax_style.SyntaxStyle, namePtr: [*]const u8, nameLen: usize) u32 { diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index 45418d5d3..7180a3287 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -47,6 +47,8 @@ pub const CliRenderer = struct { nextRenderBuffer: *OptimizedBuffer, pool: *gp.GraphemePool, backgroundColor: RGBA, + backgroundColorType: ansi.ColorType, + backgroundColorIndex: u8, renderOffset: u32, terminal: Terminal, testing: bool = false, @@ -206,6 +208,8 @@ pub const CliRenderer = struct { .nextRenderBuffer = nextBuffer, .pool = pool, .backgroundColor = .{ 0.0, 0.0, 0.0, 0.0 }, + .backgroundColorType = .rgb, + .backgroundColorIndex = 0, .renderOffset = 0, .terminal = Terminal.init(.{ .remote = remote }), .testing = testing, @@ -247,8 +251,18 @@ pub const CliRenderer = struct { .hitScissorStack = hitScissorStack, }; - try currentBuffer.clear(.{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, CLEAR_CHAR); - try nextBuffer.clear(.{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, null); + try currentBuffer.clearWithColorType( + .{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, + CLEAR_CHAR, + self.backgroundColorType, + self.backgroundColorIndex, + ); + try nextBuffer.clearWithColorType( + .{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, + null, + self.backgroundColorType, + self.backgroundColorIndex, + ); return self; } @@ -452,7 +466,12 @@ pub const CliRenderer = struct { try self.nextRenderBuffer.resize(width, height); try self.currentRenderBuffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, CLEAR_CHAR); - try self.nextRenderBuffer.clear(.{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, null); + try self.nextRenderBuffer.clearWithColorType( + .{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, + null, + self.backgroundColorType, + self.backgroundColorIndex, + ); const newHitGridSize = width * height; const currentHitGridSize = self.hitGridWidth * self.hitGridHeight; @@ -474,8 +493,10 @@ pub const CliRenderer = struct { self.terminal.setCursorPosition(@min(cursor.x, width), @min(cursor.y, height), cursor.visible); } - pub fn setBackgroundColor(self: *CliRenderer, rgba: RGBA) void { + pub fn setBackgroundColor(self: *CliRenderer, rgba: RGBA, color_type: ansi.ColorType, color_index: u8) void { self.backgroundColor = rgba; + self.backgroundColorType = color_type; + self.backgroundColorIndex = color_index; } pub fn setRenderOffset(self: *CliRenderer, offset: u32) void { @@ -674,22 +695,42 @@ pub const CliRenderer = struct { ansi.ANSI.moveToOutput(writer, x + 1, y + 1 + self.renderOffset) catch {}; - const fgR = rgbaComponentToU8(cell.fg[0]); - const fgG = rgbaComponentToU8(cell.fg[1]); - const fgB = rgbaComponentToU8(cell.fg[2]); + // Output foreground color based on color type + switch (cell.fg_color_type) { + .indexed => { + ansi.ANSI.fgIndexedColorOutput(writer, cell.fg_index) catch {}; + }, + .default => { + ansi.ANSI.fgDefaultOutput(writer) catch {}; + }, + .rgb => { + const fgR = rgbaComponentToU8(cell.fg[0]); + const fgG = rgbaComponentToU8(cell.fg[1]); + const fgB = rgbaComponentToU8(cell.fg[2]); + ansi.ANSI.fgColorOutput(writer, fgR, fgG, fgB) catch {}; + }, + } - const bgR = rgbaComponentToU8(cell.bg[0]); - const bgG = rgbaComponentToU8(cell.bg[1]); - const bgB = rgbaComponentToU8(cell.bg[2]); + // Output background color based on color type const bgA = cell.bg[3]; - - ansi.ANSI.fgColorOutput(writer, fgR, fgG, fgB) catch {}; - - // If alpha is 0 (transparent), use terminal default background instead of black + // If alpha is 0 (transparent), use terminal default background regardless of color type if (bgA < 0.001) { - writer.writeAll("\x1b[49m") catch {}; + ansi.ANSI.bgDefaultOutput(writer) catch {}; } else { - ansi.ANSI.bgColorOutput(writer, bgR, bgG, bgB) catch {}; + switch (cell.bg_color_type) { + .indexed => { + ansi.ANSI.bgIndexedColorOutput(writer, cell.bg_index) catch {}; + }, + .default => { + ansi.ANSI.bgDefaultOutput(writer) catch {}; + }, + .rgb => { + const bgR = rgbaComponentToU8(cell.bg[0]); + const bgG = rgbaComponentToU8(cell.bg[1]); + const bgB = rgbaComponentToU8(cell.bg[2]); + ansi.ANSI.bgColorOutput(writer, bgR, bgG, bgB) catch {}; + }, + } } ansi.TextAttributes.applyAttributesOutputWriter(writer, cell.attributes) catch {}; @@ -813,7 +854,12 @@ pub const CliRenderer = struct { self.renderStats.cellsUpdated = cellsUpdated; self.renderStats.renderTime = renderTime; - self.nextRenderBuffer.clear(.{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, null) catch {}; + self.nextRenderBuffer.clearWithColorType( + .{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, + null, + self.backgroundColorType, + self.backgroundColorIndex, + ) catch {}; // Compare hit grids before swap to detect changes. This allows TypeScript to // know if hover state needs rechecking without manually tracking dirty state. diff --git a/packages/core/src/zig/syntax-style.zig b/packages/core/src/zig/syntax-style.zig index 198954f26..520989a1d 100644 --- a/packages/core/src/zig/syntax-style.zig +++ b/packages/core/src/zig/syntax-style.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const buffer = @import("buffer.zig"); +const ansi = @import("ansi.zig"); const events = @import("event-emitter.zig"); pub const RGBA = buffer.RGBA; @@ -8,6 +9,10 @@ pub const RGBA = buffer.RGBA; pub const StyleDefinition = struct { fg: ?RGBA, bg: ?RGBA, + fg_color_type: ansi.ColorType = .rgb, + bg_color_type: ansi.ColorType = .rgb, + fg_index: u8 = 0, + bg_index: u8 = 0, attributes: u32, }; @@ -65,10 +70,28 @@ pub const SyntaxStyle = struct { } pub fn registerStyle(self: *SyntaxStyle, name: []const u8, fg: ?RGBA, bg: ?RGBA, attributes: u32) SyntaxStyleError!u32 { + return self.registerStyleWithColorInfo(name, fg, bg, attributes, .rgb, .rgb, 0, 0); + } + + pub fn registerStyleWithColorInfo( + self: *SyntaxStyle, + name: []const u8, + fg: ?RGBA, + bg: ?RGBA, + attributes: u32, + fg_color_type: ansi.ColorType, + bg_color_type: ansi.ColorType, + fg_index: u8, + bg_index: u8, + ) SyntaxStyleError!u32 { if (self.name_to_id.get(name)) |existing_id| { try self.id_to_style.put(self.allocator, existing_id, StyleDefinition{ .fg = fg, .bg = bg, + .fg_color_type = fg_color_type, + .bg_color_type = bg_color_type, + .fg_index = fg_index, + .bg_index = bg_index, .attributes = attributes, }); return existing_id; @@ -83,6 +106,10 @@ pub const SyntaxStyle = struct { try self.id_to_style.put(self.allocator, id, StyleDefinition{ .fg = fg, .bg = bg, + .fg_color_type = fg_color_type, + .bg_color_type = bg_color_type, + .fg_index = fg_index, + .bg_index = bg_index, .attributes = attributes, }); @@ -121,13 +148,25 @@ pub const SyntaxStyle = struct { var merged = StyleDefinition{ .fg = null, .bg = null, + .fg_color_type = .rgb, + .bg_color_type = .rgb, + .fg_index = 0, + .bg_index = 0, .attributes = 0, }; for (ids) |id| { if (self.resolveById(id)) |style| { - if (style.fg) |fg| merged.fg = fg; - if (style.bg) |bg| merged.bg = bg; + if (style.fg) |fg| { + merged.fg = fg; + merged.fg_color_type = style.fg_color_type; + merged.fg_index = style.fg_index; + } + if (style.bg) |bg| { + merged.bg = bg; + merged.bg_color_type = style.bg_color_type; + merged.bg_index = style.bg_index; + } // Attributes are OR'd together merged.attributes |= style.attributes; } diff --git a/packages/core/src/zig/tests/renderer_test.zig b/packages/core/src/zig/tests/renderer_test.zig index f0d12435c..af68250b9 100644 --- a/packages/core/src/zig/tests/renderer_test.zig +++ b/packages/core/src/zig/tests/renderer_test.zig @@ -313,7 +313,7 @@ test "renderer - background color setting" { defer cli_renderer.destroy(); const bg_color = RGBA{ 0.1, 0.2, 0.3, 1.0 }; - cli_renderer.setBackgroundColor(bg_color); + cli_renderer.setBackgroundColor(bg_color, .rgb, 0); try std.testing.expectEqual(bg_color, cli_renderer.backgroundColor); } diff --git a/packages/core/src/zig/tests/text-buffer-drawing_test.zig b/packages/core/src/zig/tests/text-buffer-drawing_test.zig index 8b0a4628a..6cd1d9851 100644 --- a/packages/core/src/zig/tests/text-buffer-drawing_test.zig +++ b/packages/core/src/zig/tests/text-buffer-drawing_test.zig @@ -2,6 +2,7 @@ const std = @import("std"); const text_buffer = @import("../text-buffer.zig"); const text_buffer_view = @import("../text-buffer-view.zig"); const buffer = @import("../buffer.zig"); +const ansi = @import("../ansi.zig"); const gp = @import("../grapheme.zig"); const ss = @import("../syntax-style.zig"); @@ -32,7 +33,8 @@ test "drawTextBuffer - simple single line text" { ); defer opt_buffer.deinit(); - try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, 32); + // Start with a transparent background so a "default"/transparent bg style doesn't blend into an opaque bg. + try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 0.0 }, 32); try opt_buffer.drawTextBuffer(view, 0, 0); var out_buffer: [100]u8 = undefined; @@ -671,6 +673,53 @@ test "setStyledText - multiple chunks render correctly" { try std.testing.expectEqualStrings("Hello World", result); } +test "setStyledText - preserves indexed/default color types" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .unicode); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + const style = try ss.SyntaxStyle.init(std.testing.allocator); + defer style.deinit(); + tb.setSyntaxStyle(style); + + const text = "X"; + const fg_color = [4]f32{ 0.0, 0.0, 0.0, 1.0 }; // actual RGB value is ignored for indexed/default + const bg_color = [4]f32{ 0.0, 0.0, 0.0, 0.0 }; // transparent + + const chunks = [_]StyledChunk{.{ + .text_ptr = text.ptr, + .text_len = text.len, + .fg_ptr = @ptrCast(&fg_color), + .bg_ptr = @ptrCast(&bg_color), + .attributes = 0, + .fg_color_type = @intFromEnum(ansi.ColorType.indexed), + .bg_color_type = @intFromEnum(ansi.ColorType.default), + .fg_index = 1, + .bg_index = 0, + }}; + + try tb.setStyledText(&chunks); + + var opt_buffer = try OptimizedBuffer.init( + std.testing.allocator, + 5, + 1, + .{ .pool = pool, .width_method = .unicode }, + ); + defer opt_buffer.deinit(); + + try opt_buffer.clear(.{ 0.0, 0.0, 0.0, 1.0 }, 32); + try opt_buffer.drawTextBuffer(view, 0, 0); + + try std.testing.expectEqual(ansi.ColorType.indexed, opt_buffer.buffer.fg_color_type[0]); + try std.testing.expectEqual(@as(u8, 1), opt_buffer.buffer.fg_index[0]); +} + // Viewport Tests test "viewport - basic vertical scrolling limits returned lines" { diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig index 81f778222..0f2c1d514 100644 --- a/packages/core/src/zig/text-buffer.zig +++ b/packages/core/src/zig/text-buffer.zig @@ -4,6 +4,7 @@ const seg_mod = @import("text-buffer-segment.zig"); const iter_mod = @import("text-buffer-iterators.zig"); const mem_registry_mod = @import("mem-registry.zig"); const ss = @import("syntax-style.zig"); +const ansi = @import("ansi.zig"); const gp = @import("grapheme.zig"); const utf8 = @import("utf8.zig"); @@ -37,6 +38,11 @@ pub const StyledChunk = extern struct { fg_ptr: ?[*]const f32, bg_ptr: ?[*]const f32, attributes: u32, + // Keep these at the end to minimize ABI churn. + fg_color_type: u8 = 0, // ansi.ColorType (0=rgb, 1=indexed, 2=default) + bg_color_type: u8 = 0, + fg_index: u8 = 0, + bg_index: u8 = 0, }; pub const UnifiedTextBuffer = struct { @@ -946,9 +952,21 @@ pub const UnifiedTextBuffer = struct { const fg = if (chunk.fg_ptr) |fgPtr| utils.f32PtrToRGBA(fgPtr) else null; const bg = if (chunk.bg_ptr) |bgPtr| utils.f32PtrToRGBA(bgPtr) else null; + const fg_color_type: ansi.ColorType = @enumFromInt(chunk.fg_color_type); + const bg_color_type: ansi.ColorType = @enumFromInt(chunk.bg_color_type); + var style_name_buf: [64]u8 = undefined; const style_name = std.fmt.bufPrint(&style_name_buf, "chunk{d}", .{i}) catch continue; - const style_id = (@constCast(style)).registerStyle(style_name, fg, bg, chunk.attributes) catch continue; + const style_id = (@constCast(style)).registerStyleWithColorInfo( + style_name, + fg, + bg, + chunk.attributes, + fg_color_type, + bg_color_type, + chunk.fg_index, + chunk.bg_index, + ) catch continue; self.addHighlightByCharRange(char_pos, char_pos + chunk_len, style_id, 1, 0) catch {}; }