From 3856d57ac13564a3f417bc30faff17bb6e17c43d Mon Sep 17 00:00:00 2001 From: James Yong Date: Fri, 2 Oct 2026 22:54:40 -0700 Subject: [PATCH 1/2] feat: palette provenance, minimum-contrast, and a light-adaptation setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRF1 gains section 13 (StylePalette): for styles whose explicit colors came from the terminal palette, the palette index beside the resolved RGB. It is additive — old decoders skip it and render the same RGB — and omitted when no style has provenance. Style ids include provenance only when present, so one RGB reached by palette and by truecolor stays two definitions. The phase-1 golden frame is regenerated for that intentional change. Light adaptation keeps a light theme's own chromatic ANSI colors (1–6, 9–14) when they stay legible on the remapped surface; neutral entries still remap by role. `minimum-contrast` (Ghostty key, 1–21) is now applied to text glyphs and their decorations, hue-preserving rather than snapping to black or white; box drawing, block elements, and color glyphs are exempt. `ghosttea-light-adaptation = auto|off` is the first Ghosttea extension key, accepted beside the pinned Ghostty schema. Both settings reach remote views through the terminal presentation, the protocol validates them, and Settings → Appearance → Readability writes them into the managed block. Co-Authored-By: Claude Opus 5.5 --- .../src/main/appearance-config.test.ts | 23 ++ apps/desktop/src/main/appearance-config.ts | 34 +++ apps/desktop/src/main/config-editor.test.ts | 2 + apps/desktop/src/main/config-editor.ts | 1 + docs/ghostty-config-compatibility.md | 22 +- docs/webgpu-appearance-pipeline.md | 21 +- .../crates/ghosttea-config/src/lib.rs | 125 ++++++++- .../crates/ghosttea-core/src/frame.rs | 260 ++++++++++++++++-- .../crates/ghosttea-core/src/replica.rs | 17 +- .../fixtures/phase1/ansi-baseline.json | 2 +- native/ghosttea/src/lib.rs | 2 +- packages/ghosttea-frame/README.md | 15 + packages/ghosttea-frame/src/index.test.ts | 33 +++ packages/ghosttea-frame/src/index.ts | 50 +++- packages/ghosttea-protocol/src/index.test.ts | 14 + packages/ghosttea-protocol/src/index.ts | 13 + .../src/appearance/AppearanceSettings.test.ts | 10 + .../src/appearance/AppearanceSettings.tsx | 43 ++- .../ghosttea-react/src/appearance/types.ts | 4 + packages/ghosttea-react/src/config.test.ts | 4 + packages/ghosttea-react/src/config.ts | 3 +- .../src/renderers/light-adaptation.test.ts | 55 +++- .../src/renderers/light-adaptation.ts | 93 ++++++- .../ghosttea-react/src/renderers/types.ts | 6 + .../src/renderers/webgpu-renderer.ts | 40 ++- .../src/terminal-render.worker.ts | 3 +- 26 files changed, 825 insertions(+), 70 deletions(-) diff --git a/apps/desktop/src/main/appearance-config.test.ts b/apps/desktop/src/main/appearance-config.test.ts index c29e032b..674c40f9 100644 --- a/apps/desktop/src/main/appearance-config.test.ts +++ b/apps/desktop/src/main/appearance-config.test.ts @@ -124,6 +124,29 @@ describe("managed appearance config", () => { ).toEqual(["background", "custom-shader"]); }); + it("writes readability settings only when the renderer sends them", () => { + const readable = validateAppearanceUpdate({ ...update, lightAdaptation: "off", minimumContrast: 4.567 }); + expect(readable).toMatchObject({ lightAdaptation: "off", minimumContrast: 4.57 }); + const block = appearanceBlock(readable); + expect(block).toContain("ghosttea-light-adaptation = off\nminimum-contrast = 4.57\n"); + expect(appearanceBlock(validateAppearanceUpdate(update))).not.toMatch(/light-adaptation|minimum-contrast/u); + expect(() => validateAppearanceUpdate({ ...update, lightAdaptation: "maybe" })).toThrow("auto or off"); + expect(() => validateAppearanceUpdate({ ...update, minimumContrast: 0 })).toThrow("between 1 and 21"); + + const renderer = { lightAdaptation: "auto", minimumContrast: 1 } as RendererConfig; + const customColors: ManagedAppearanceUpdate = { + backgroundOpacity: 1, + backgroundOpacityCells: false, + shaderEffects: [], + shaderAnimation: false, + lightAdaptation: "off", + minimumContrast: 4.57, + }; + expect(appearanceUpdateMismatches(renderer, customColors)).toEqual( + expect.arrayContaining(["ghosttea-light-adaptation", "minimum-contrast"]), + ); + }); + it("refuses ambiguous or incomplete managed markers", () => { expect(() => patchAppearanceBlock(`${APPEARANCE_BLOCK_START}\n`, appearanceBlock(update))).toThrow("malformed"); expect(() => diff --git a/apps/desktop/src/main/appearance-config.ts b/apps/desktop/src/main/appearance-config.ts index fca8c4ff..a7dea0ac 100644 --- a/apps/desktop/src/main/appearance-config.ts +++ b/apps/desktop/src/main/appearance-config.ts @@ -15,6 +15,10 @@ export interface ManagedAppearanceUpdate { backgroundOpacityCells: boolean; shaderEffects: string[]; shaderAnimation: boolean; + /** Omitted by older renderers; the block then leaves the setting to other layers. */ + lightAdaptation?: "auto" | "off"; + /** WCAG ratio from 1 (off) through 21; omitted by older renderers. */ + minimumContrast?: number; } export const APPEARANCE_BLOCK_START = "# >>> Ghosttea appearance (managed; edit through Settings)"; @@ -81,6 +85,18 @@ export function validateAppearanceUpdate(payload: unknown): ManagedAppearanceUpd if (typeof update.backgroundOpacityCells !== "boolean" || typeof update.shaderAnimation !== "boolean") { throw new Error("Invalid appearance toggle"); } + if (update.lightAdaptation !== undefined && update.lightAdaptation !== "auto" && update.lightAdaptation !== "off") { + throw new Error("Light adaptation must be auto or off"); + } + if ( + update.minimumContrast !== undefined && + (typeof update.minimumContrast !== "number" || + !Number.isFinite(update.minimumContrast) || + update.minimumContrast < 1 || + update.minimumContrast > 21) + ) { + throw new Error("Minimum contrast must be between 1 and 21"); + } if ( !Array.isArray(update.shaderEffects) || !update.shaderEffects.every((id) => typeof id === "string" && BUNDLED_SHADER_IDS.has(id)) @@ -93,9 +109,16 @@ export function validateAppearanceUpdate(payload: unknown): ManagedAppearanceUpd backgroundOpacityCells: update.backgroundOpacityCells, shaderEffects: [...new Set(update.shaderEffects as string[])], shaderAnimation: update.shaderAnimation, + ...(update.lightAdaptation !== undefined ? { lightAdaptation: update.lightAdaptation } : {}), + ...(update.minimumContrast !== undefined ? { minimumContrast: contrastValue(update.minimumContrast) } : {}), }; } +/** Two decimals are enough for a contrast ratio and keep the config readable. */ +function contrastValue(ratio: number): number { + return Number(ratio.toFixed(2)); +} + export function appearanceBlock(update: ManagedAppearanceUpdate): string { return [ APPEARANCE_BLOCK_START, @@ -117,6 +140,8 @@ export function appearanceBlock(update: ManagedAppearanceUpdate): string { "custom-shader =", ...update.shaderEffects.map((id) => `custom-shader = ${id}`), `custom-shader-animation = ${update.shaderAnimation}`, + ...(update.lightAdaptation !== undefined ? [`ghosttea-light-adaptation = ${update.lightAdaptation}`] : []), + ...(update.minimumContrast !== undefined ? [`minimum-contrast = ${contrastValue(update.minimumContrast)}`] : []), APPEARANCE_BLOCK_END, ].join("\n"); } @@ -199,5 +224,14 @@ export function appearanceUpdateMismatches(renderer: RendererConfig, update: Man if ((renderer.customShaderAnimation ?? false) !== update.shaderAnimation) { mismatches.push("custom-shader-animation"); } + if (update.lightAdaptation !== undefined && (renderer.lightAdaptation ?? "auto") !== update.lightAdaptation) { + mismatches.push("ghosttea-light-adaptation"); + } + if ( + update.minimumContrast !== undefined && + Math.abs((renderer.minimumContrast ?? 1) - contrastValue(update.minimumContrast)) > 0.0001 + ) { + mismatches.push("minimum-contrast"); + } return mismatches; } diff --git a/apps/desktop/src/main/config-editor.test.ts b/apps/desktop/src/main/config-editor.test.ts index a46b2c16..6bdb199b 100644 --- a/apps/desktop/src/main/config-editor.test.ts +++ b/apps/desktop/src/main/config-editor.test.ts @@ -37,6 +37,7 @@ describe("desktop config editor boundary", () => { palette: [{ index: 2, color: [4, 5, 6] }], backgroundOpacity: 0.73, backgroundOpacityCells: true, + minimumContrast: 4.5, fontSize: 14, fontFamilies: ["JetBrains Mono"], paddingX: [3, 4], @@ -54,6 +55,7 @@ describe("desktop config editor boundary", () => { const result = serializeSupportedGhosttyConfig(config); expect(result).toContain("foreground = #f0f1f2\n"); expect(result).toContain("palette = 2=#040506\n"); + expect(result).toContain("minimum-contrast = 4.5\n"); expect(result).toContain("window-padding-x = 3,4\nwindow-padding-y = 5\n"); expect(result).toContain("custom-shader = ghosttea:crt\n"); expect(result).toContain("keybind = clear\nkeybind = super+t=new_tab\n"); diff --git a/apps/desktop/src/main/config-editor.ts b/apps/desktop/src/main/config-editor.ts index 9a7c9f74..a05d0357 100644 --- a/apps/desktop/src/main/config-editor.ts +++ b/apps/desktop/src/main/config-editor.ts @@ -202,6 +202,7 @@ export function serializeSupportedGhosttyConfig(config: ConfigSnapshot): string .map(({ index, color }) => `palette = ${index}=${colorHex(color)}`), `background-opacity = ${(renderer.backgroundOpacity ?? 1).toFixed(2)}`, `background-opacity-cells = ${renderer.backgroundOpacityCells ?? false}`, + `minimum-contrast = ${renderer.minimumContrast ?? 1}`, `scrollback-limit = ${config.terminal.scrollbackBytes}`, "font-family =", ...renderer.fontFamilies.map((family) => `font-family = ${family.replaceAll(/[\r\n]/gu, "")}`), diff --git a/docs/ghostty-config-compatibility.md b/docs/ghostty-config-compatibility.md index ba2a2f5d..ac28f474 100644 --- a/docs/ghostty-config-compatibility.md +++ b/docs/ghostty-config-compatibility.md @@ -47,16 +47,18 @@ Electron terminal links apply `link-url` (default `true`). This controls plain URL matching; OSC 8 targets remain clickable when it is false. The host must provide `platform.openExternal` and use a daemon that emits TRF1 link targets. -| Area | Daemon / Electron | Swift / Metal | -| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Foreground, background, cursor, selection colors | Fixed colors are applied live; dynamic `cell-foreground`/`cell-background` references are diagnosed as parsed until per-cell rendering is implemented | Device config applies live to local SSH and shared sessions; protocol minor 8 keeps defaults and indexed colors semantic while application-owned truecolor remains unchanged | -| `cursor-text`, `palette` | Applied live. Sparse palette entries override libghostty's default 256-color table; the Settings catalog writes entries 0–15 | Applied live from the device config. Shared palette cells are re-resolved locally without rebuilding text shapes or losing selection and patch continuity | -| `background-opacity`, `background-opacity-cells` | WebGPU and Canvas2D preserve premultiplied alpha; macOS Electron windows expose it to the desktop. Explicit cell backgrounds follow the cells toggle | Applied to the Metal terminal layer for local and shared terminals; iOS does not make the app window itself transparent | -| `scrollback-limit` | Applied to new sessions; default is Ghostty's 10,000,000 bytes | Available through `GhostteaTerminalConfiguration(config:)`; the core default is 10,000,000 bytes, while the iOS app may deliberately impose its device memory budget | -| `keybind` | Mutations overlay the pinned 93-entry platform table; `clear`, blank reset, `unbind`, supported single-stroke actions, and `unconsumed:` are applied | Preserved in the shared API; the Swift workspace still has a smaller hand-written action router | -| `window-padding-x/y` | Parsed, not yet used by the fixed desktop cell geometry | Applied by `GhostteaTerminalMetalView.applyConfiguration(_:)` | -| `font-family`, `font-size` | Parsed, but runtime font metrics are still selected at process startup | `font-size` scales shaping and grid metrics and atomically reshapes a live shared replica; arbitrary `font-family` values remain diagnosed because Apple uses bundled fonts | -| `custom-shader`, `custom-shader-animation` | Namespaced built-ins compose in declaration order through ping-pong WebGPU passes. Canvas fallback and arbitrary Ghostty GLSL are unsupported | Namespaced built-ins compose in declaration order through Metal passes and remain device-owned in shared sessions; arbitrary Ghostty shader paths are diagnosed/preserved | +| Area | Daemon / Electron | Swift / Metal | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Foreground, background, cursor, selection colors | Fixed colors are applied live; dynamic `cell-foreground`/`cell-background` references are diagnosed as parsed until per-cell rendering is implemented | Device config applies live to local SSH and shared sessions; protocol minor 8 keeps defaults and indexed colors semantic while application-owned truecolor remains unchanged | +| `cursor-text`, `palette` | Applied live. Sparse palette entries override libghostty's default 256-color table; the Settings catalog writes entries 0–15 | Applied live from the device config. Shared palette cells are re-resolved locally without rebuilding text shapes or losing selection and patch continuity | +| `background-opacity`, `background-opacity-cells` | WebGPU and Canvas2D preserve premultiplied alpha; macOS Electron windows expose it to the desktop. Explicit cell backgrounds follow the cells toggle | Applied to the Metal terminal layer for local and shared terminals; iOS does not make the app window itself transparent | +| `minimum-contrast` | Applied live to text glyphs and decorations by WebGPU, preserving hue instead of snapping to black or white; box drawing, block elements, and color glyphs are exempt | Applied by the Metal renderer with the same rules | +| `ghosttea-light-adaptation` (Ghosttea key) | `auto` (default) restyles panes an application paints dark while a light theme is active; `off` renders application colors as sent | Same remap in the Metal renderer; also carried in the remote presentation | +| `scrollback-limit` | Applied to new sessions; default is Ghostty's 10,000,000 bytes | Available through `GhostteaTerminalConfiguration(config:)`; the core default is 10,000,000 bytes, while the iOS app may deliberately impose its device memory budget | +| `keybind` | Mutations overlay the pinned 93-entry platform table; `clear`, blank reset, `unbind`, supported single-stroke actions, and `unconsumed:` are applied | Preserved in the shared API; the Swift workspace still has a smaller hand-written action router | +| `window-padding-x/y` | Parsed, not yet used by the fixed desktop cell geometry | Applied by `GhostteaTerminalMetalView.applyConfiguration(_:)` | +| `font-family`, `font-size` | Parsed, but runtime font metrics are still selected at process startup | `font-size` scales shaping and grid metrics and atomically reshapes a live shared replica; arbitrary `font-family` values remain diagnosed because Apple uses bundled fonts | +| `custom-shader`, `custom-shader-animation` | Namespaced built-ins compose in declaration order through ping-pong WebGPU passes. Canvas fallback and arbitrary Ghostty GLSL are unsupported | Namespaced built-ins compose in declaration order through Metal passes and remain device-owned in shared sessions; arbitrary Ghostty shader paths are diagnosed/preserved | Other recognized keys—including `theme`, shell integration, and most window/application behavior—are currently reported diff --git a/docs/webgpu-appearance-pipeline.md b/docs/webgpu-appearance-pipeline.md index 9519bf79..4a21e38c 100644 --- a/docs/webgpu-appearance-pipeline.md +++ b/docs/webgpu-appearance-pipeline.md @@ -50,8 +50,8 @@ late overlay geometry. Some applications paint every cell with their own truecolor background, so a light theme never shows through (Grok's default theme paints `#141414` -everywhere). With `lightAdaptation: "auto"`, the default from configuration, -a pane is remapped when the theme background is light and at least 60% of its +everywhere). With `ghosttea-light-adaptation = auto` (the default; Settings → +Appearance → Readability, or `off` to disable), a pane is remapped when the theme background is light and at least 60% of its cells carry an explicit dark background. It releases below 35%, so partially painted scrolls do not flicker. Applications that follow the theme (Codex, OpenCode, Claude Code's Auto theme) never cross that threshold. @@ -61,9 +61,24 @@ coverage, color glyphs, and the shader stack are untouched. Surfaces mirror their OKLab-with-toe lightness around the theme background, anchored at the application's dominant surface. Neutral ink keeps its distance from its own cell background, and accents land in an APCA mid-tone band with their hue -intact. Default foreground and background stay theme-owned. Engaging, +intact. Default foreground and background stay theme-owned, and so do the +chromatic ANSI colors (palette 1–6 and 9–14): TRF1 section 13 tells the +renderer which palette entry a style color came from, and the light theme's own +entry is kept unless the remapped surface beneath leaves it less legible than +on the theme background (capped at APCA Lc 45). Neutral entries (0, 7, 8, 15) +describe a role rather than a hue and remap like truecolor. Engaging, re-keying, or releasing invalidates the persistent scene and geometry cache. +## Minimum contrast + +Ghostty's `minimum-contrast` (WCAG ratio 1–21, 1 is off) applies to text +glyphs and their underline and strikethrough, measured against the cell +background composited over the theme background, after light adaptation. +Ghostty snaps failing text to black or white; Ghosttea keeps hue and chroma and +moves OKLab lightness toward whichever extreme contrasts more, only as far as +the ratio needs. Box drawing, block elements, and color glyphs are exempt, as +in Ghostty. + Applications that switch themes themselves need the terminal to say which scheme it is. ghosttead answers `CSI ? 996 n` from the default background's luminance, sends `CSI ? 997 ; 1|2 n` when a theme change flips it and mode diff --git a/native/ghosttea/crates/ghosttea-config/src/lib.rs b/native/ghosttea/crates/ghosttea-config/src/lib.rs index d1752cc9..c889c897 100644 --- a/native/ghosttea/crates/ghosttea-config/src/lib.rs +++ b/native/ghosttea/crates/ghosttea-config/src/lib.rs @@ -31,6 +31,9 @@ pub const GHOSTTEA_BETTER_CRT_SHADER: &str = "ghosttea:better-crt"; pub const GHOSTTEA_CRT_SHADER: &str = "ghosttea:crt"; pub const GHOSTTEA_VHS_SHADER: &str = "ghosttea:vhs"; pub const GHOSTTEA_SPARKS_SHADER: &str = "ghosttea:sparks-from-fire"; +/// Ghosttea-only keys accepted beside the pinned Ghostty schema. The prefix +/// keeps them clear of future upstream keys. +pub const GHOSTTEA_EXTENSION_KEYS: &[&str] = &["ghosttea-light-adaptation"]; pub const CONFIG_DOCUMENT_SCHEMA_VERSION: u32 = 1; /// Keeps a raw document plus worst-case JSON escaping below the 1 MiB control /// packet quota. Ghostty configuration should reference large assets by path. @@ -132,6 +135,8 @@ impl ConfigSnapshot { palette: self.renderer.palette.clone(), background_opacity: self.renderer.background_opacity, background_opacity_cells: self.renderer.background_opacity_cells, + minimum_contrast: self.renderer.minimum_contrast, + light_adaptation: self.renderer.light_adaptation, font_size: self.renderer.font_size, font_families: self.renderer.font_families.clone(), padding_x: self.renderer.padding_x, @@ -249,6 +254,11 @@ pub struct RendererConfig { pub background_opacity: f32, #[serde(default)] pub background_opacity_cells: bool, + /// WCAG contrast ratio text keeps against its cell background (1 = off). + #[serde(default = "default_minimum_contrast")] + pub minimum_contrast: f32, + #[serde(default)] + pub light_adaptation: LightAdaptation, pub font_size: f32, pub font_families: Vec, pub padding_x: [f32; 2], @@ -283,6 +293,11 @@ pub struct TerminalPresentationConfig { pub background_opacity: f32, #[serde(default)] pub background_opacity_cells: bool, + /// WCAG contrast ratio text keeps against its cell background (1 = off). + #[serde(default = "default_minimum_contrast")] + pub minimum_contrast: f32, + #[serde(default)] + pub light_adaptation: LightAdaptation, pub font_size: f32, pub font_families: Vec, pub padding_x: [f32; 2], @@ -315,6 +330,10 @@ struct TerminalPresentationConfigWire { background_opacity: f32, #[serde(default)] background_opacity_cells: bool, + #[serde(default = "default_minimum_contrast")] + minimum_contrast: f32, + #[serde(default)] + light_adaptation: LightAdaptation, font_size: f32, font_families: Vec, padding_x: [f32; 2], @@ -345,6 +364,8 @@ impl<'de> Deserialize<'de> for TerminalPresentationConfig { palette: wire.palette, background_opacity: wire.background_opacity, background_opacity_cells: wire.background_opacity_cells, + minimum_contrast: wire.minimum_contrast, + light_adaptation: wire.light_adaptation, font_size: wire.font_size, font_families: wire.font_families, padding_x: wire.padding_x, @@ -357,6 +378,16 @@ impl<'de> Deserialize<'de> for TerminalPresentationConfig { } } +/// Whether a light theme may restyle applications that paint their own dark +/// backgrounds (`ghosttea-light-adaptation`). +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum LightAdaptation { + #[default] + Auto, + Off, +} + #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum RendererPostProcess { @@ -369,6 +400,10 @@ const fn default_background_opacity() -> f32 { 1.0 } +const fn default_minimum_contrast() -> f32 { + 1.0 +} + const fn default_cursor_text() -> [u8; 3] { DEFAULT_BACKGROUND } @@ -517,6 +552,8 @@ impl Default for ConfigSnapshot { palette: Vec::new(), background_opacity: 1.0, background_opacity_cells: false, + minimum_contrast: 1.0, + light_adaptation: LightAdaptation::Auto, font_size: platform_default_font_size(), font_families: Vec::new(), padding_x: [2.0, 2.0], @@ -1311,7 +1348,9 @@ fn project(mut state: LoadState) -> ConfigSnapshot { let mut clear_keybindings = false; for setting in state.settings { - if !known.contains(setting.key.as_str()) { + if !known.contains(setting.key.as_str()) + && !GHOSTTEA_EXTENSION_KEYS.contains(&setting.key.as_str()) + { state.diagnostics.push(diagnostic_at( DiagnosticSeverity::Warning, "unknown-key", @@ -1459,6 +1498,42 @@ fn project(mut state: LoadState) -> ConfigSnapshot { )), } } + for setting in scalars + .iter() + .filter(|setting| setting.key == "minimum-contrast") + { + if setting.value.is_empty() { + snapshot.renderer.minimum_contrast = 1.0; + continue; + } + // Ghostty clamps rather than rejects out-of-range ratios. + match setting.value.parse::() { + Ok(value) if value.is_finite() => { + snapshot.renderer.minimum_contrast = value.clamp(1.0, 21.0); + } + _ => state.diagnostics.push(diagnostic_at( + DiagnosticSeverity::Error, + "invalid-value", + "`minimum-contrast` must be a contrast ratio from 1 through 21".to_owned(), + setting, + )), + } + } + for setting in scalars + .iter() + .filter(|setting| setting.key == "ghosttea-light-adaptation") + { + match setting.value.trim() { + "" | "auto" => snapshot.renderer.light_adaptation = LightAdaptation::Auto, + "off" => snapshot.renderer.light_adaptation = LightAdaptation::Off, + _ => state.diagnostics.push(diagnostic_at( + DiagnosticSeverity::Error, + "invalid-value", + "`ghosttea-light-adaptation` must be `auto` or `off`".to_owned(), + setting, + )), + } + } for setting in scalars.iter().filter(|setting| setting.key == "link-url") { if setting.value.is_empty() { snapshot.renderer.link_url = true; @@ -1953,6 +2028,8 @@ fn support_for_key(key: &str) -> ConfigSupport { | "background" | "background-opacity" | "background-opacity-cells" + | "minimum-contrast" + | "ghosttea-light-adaptation" | "foreground" | "cursor-color" | "cursor-text" @@ -3059,4 +3136,50 @@ mod tests { .link_url ); } + #[test] + fn minimum_contrast_and_light_adaptation_apply_and_reach_remote_views() { + let temporary = TempDir::new().unwrap(); + let config = temporary.path().join("config"); + write( + &config, + "minimum-contrast = 4.5\nghosttea-light-adaptation = off\n", + ); + let snapshot = load_config(&ConfigLoadOptions::explicit(&config)); + assert_eq!(snapshot.renderer.minimum_contrast, 4.5); + assert_eq!(snapshot.renderer.light_adaptation, LightAdaptation::Off); + assert!( + snapshot.diagnostics.is_empty(), + "{:?}", + snapshot.diagnostics + ); + for key in ["minimum-contrast", "ghosttea-light-adaptation"] { + assert!( + snapshot + .configured_keys + .iter() + .any(|configured| configured.key == key + && configured.support == ConfigSupport::Applied), + "{key}" + ); + } + let presentation = snapshot.terminal_presentation(); + assert_eq!(presentation.minimum_contrast, 4.5); + assert_eq!(presentation.light_adaptation, LightAdaptation::Off); + + // Ghostty clamps the ratio; an empty value resets both keys. + write(&config, "minimum-contrast = 40\n"); + let snapshot = load_config(&ConfigLoadOptions::explicit(&config)); + assert_eq!(snapshot.renderer.minimum_contrast, 21.0); + assert_eq!(snapshot.renderer.light_adaptation, LightAdaptation::Auto); + write( + &config, + "minimum-contrast = 3\nminimum-contrast =\nghosttea-light-adaptation = sometimes\n", + ); + let snapshot = load_config(&ConfigLoadOptions::explicit(&config)); + assert_eq!(snapshot.renderer.minimum_contrast, 1.0); + assert!(snapshot.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "invalid-value" + && diagnostic.key.as_deref() == Some("ghosttea-light-adaptation") + })); + } } diff --git a/native/ghosttea/crates/ghosttea-core/src/frame.rs b/native/ghosttea/crates/ghosttea-core/src/frame.rs index 96998282..c3262dfc 100644 --- a/native/ghosttea/crates/ghosttea-core/src/frame.rs +++ b/native/ghosttea/crates/ghosttea-core/src/frame.rs @@ -16,6 +16,7 @@ pub const GLYPH_DEFINITIONS: u16 = 1; pub const CLIPBOARD_WRITE: u16 = 11; pub const SELECTION_SPANS: u16 = 5; pub const LINK_TARGETS: u16 = 12; +pub const STYLE_PALETTE: u16 = 13; pub const FULL_SNAPSHOT: u16 = 1; pub const MOUSE_TRACKING: u16 = 1 << 1; pub const CATALOG_RESET: u16 = 1 << 2; @@ -62,7 +63,29 @@ fn encode_glyph_definitions(definitions: &[GlyphDefinition]) -> Result<(Vec, Ok((bytes, count)) } -fn style_id(style: CellStyle) -> u32 { +/// Palette entries a style's explicit colors were resolved from. Renderers use +/// it to tell theme-designed ANSI colors from application truecolor; the RGB +/// in the style definition stays authoritative for decoders that ignore it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct StylePalette { + pub foreground: Option, + pub background: Option, +} + +impl StylePalette { + fn is_empty(self) -> bool { + self.foreground.is_none() && self.background.is_none() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct FrameStyle { + pub style: CellStyle, + pub palette: StylePalette, +} + +fn style_id(style: FrameStyle) -> u32 { + let FrameStyle { style, palette } = style; if style == CellStyle::default() { return 0; } @@ -85,6 +108,22 @@ fn style_id(style: CellStyle) -> u32 { .chain(foreground) .chain([u8::from(style.background.is_some())]) .chain(background) + // Palette provenance only extends the hash when present, so ids of + // truecolor styles are unchanged and same-RGB sources stay distinct. + .chain( + (!palette.is_empty()) + .then(|| { + [ + 2, + u8::from(palette.foreground.is_some()), + palette.foreground.unwrap_or(0), + u8::from(palette.background.is_some()), + palette.background.unwrap_or(0), + ] + }) + .into_iter() + .flatten(), + ) { hash ^= byte as u32; hash = hash.wrapping_mul(16_777_619); @@ -92,10 +131,10 @@ fn style_id(style: CellStyle) -> u32 { if hash == 0 { 1 } else { hash } } -fn encode_style_definitions(styles: impl ExactSizeIterator) -> Vec { +fn encode_style_definitions(styles: &[(u32, FrameStyle)]) -> Vec { let mut bytes = Vec::with_capacity(4 + styles.len() * 16); bytes.extend_from_slice(&(styles.len() as u32).to_le_bytes()); - for (id, style) in styles { + for &(id, FrameStyle { style, .. }) in styles { let flags = u16::from(style.bold) | (u16::from(style.italic) << 1) | (u16::from(style.faint) << 2) @@ -116,15 +155,37 @@ fn encode_style_definitions(styles: impl ExactSizeIterator (Vec, u32) { + let entries = styles + .iter() + .filter(|(_, style)| !style.palette.is_empty()) + .collect::>(); + let mut bytes = Vec::with_capacity(4 + entries.len() * 8); + bytes.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + for (id, FrameStyle { palette, .. }) in &entries { + bytes.extend_from_slice(&id.to_le_bytes()); + bytes.push( + u8::from(palette.foreground.is_some()) | (u8::from(palette.background.is_some()) << 1), + ); + bytes.push(palette.foreground.unwrap_or(0)); + bytes.push(palette.background.unwrap_or(0)); + bytes.push(0); + } + (bytes, entries.len() as u32) +} + struct PreparedRowStyles { cell_ids: Vec, column_ids: Vec, } enum PreparedFrameStyles { - Linear(BTreeMap), + Linear(BTreeMap), Indexed { - definitions: HashMap, + definitions: HashMap, rows: Vec, }, } @@ -137,18 +198,16 @@ impl PreparedFrameStyles { } } - fn encode(&self) -> Vec { + fn sorted(&self) -> Vec<(u32, FrameStyle)> { match self { - Self::Linear(styles) => { - encode_style_definitions(styles.iter().map(|(&id, &style)| (id, style))) - } + Self::Linear(styles) => styles.iter().map(|(&id, &style)| (id, style)).collect(), Self::Indexed { definitions, .. } => { let mut sorted = definitions .iter() .map(|(&id, &style)| (id, style)) .collect::>(); sorted.sort_unstable_by_key(|(id, _)| *id); - encode_style_definitions(sorted.into_iter()) + sorted } } } @@ -161,6 +220,14 @@ pub(crate) trait FrameCell { fn column(&self) -> u16; fn span(&self) -> u16; fn style(&self) -> CellStyle; + fn palette(&self) -> StylePalette; + + fn frame_style(&self) -> FrameStyle { + FrameStyle { + style: self.style(), + palette: self.palette(), + } + } } impl FrameCell for TerminalCell { @@ -175,6 +242,17 @@ impl FrameCell for TerminalCell { fn style(&self) -> CellStyle { self.style } + + fn palette(&self) -> StylePalette { + StylePalette { + foreground: self + .foreground_palette + .filter(|_| self.style.foreground.is_some()), + background: self + .background_palette + .filter(|_| self.style.background.is_some()), + } + } } fn append_style_run(runs: &mut Vec<(u32, u16, u16)>, cell: &C, id: u32) { @@ -194,12 +272,12 @@ fn append_style_run(runs: &mut Vec<(u32, u16, u16)>, cell: &C, id: fn prepare_row_styles( cells: &[C], shaped: &ShapedRow, - styles: &mut HashMap, + styles: &mut HashMap, ) -> PreparedRowStyles { let cell_ids = cells .iter() .map(|cell| { - let style = cell.style(); + let style = cell.frame_style(); let id = style_id(style); styles.entry(id).or_insert(style); id @@ -339,7 +417,7 @@ pub(crate) fn encode_frame_text_snapshot( .any(|row| cells[*row as usize].len() >= 8); let prepared_styles = if indexed_styles { let mut definitions = HashMap::new(); - definitions.insert(0, CellStyle::default()); + definitions.insert(0, FrameStyle::default()); let rows = updated_rows .iter() .map(|row| { @@ -350,17 +428,19 @@ pub(crate) fn encode_frame_text_snapshot( PreparedFrameStyles::Indexed { definitions, rows } } else { let mut definitions = BTreeMap::new(); - definitions.insert(0, CellStyle::default()); + definitions.insert(0, FrameStyle::default()); for row in updated_rows { for cell in &cells[*row as usize] { - let style = cell.style(); + let style = cell.frame_style(); definitions.entry(style_id(style)).or_insert(style); } } PreparedFrameStyles::Linear(definitions) }; let (glyph_definitions, glyph_count) = encode_glyph_definitions(new_glyph_definitions)?; - let style_definitions = prepared_styles.encode(); + let sorted_styles = prepared_styles.sorted(); + let style_definitions = encode_style_definitions(&sorted_styles); + let (style_palette, style_palette_count) = encode_style_palette(&sorted_styles); let mut accessibility = Vec::new(); accessibility.extend_from_slice(&(updated_rows.len() as u16).to_le_bytes()); let mut replacements = Vec::new(); @@ -385,7 +465,7 @@ pub(crate) fn encode_frame_text_snapshot( match &prepared_styles { PreparedFrameStyles::Linear(_) => { for cell in row_cells { - append_style_run(&mut runs, cell, style_id(cell.style())); + append_style_run(&mut runs, cell, style_id(cell.frame_style())); } } PreparedFrameStyles::Indexed { rows, .. } => { @@ -405,7 +485,7 @@ pub(crate) fn encode_frame_text_snapshot( glyph.cell_start >= cell.column() && glyph.cell_start < cell.column().saturating_add(cell.span()) }) - .map(|cell| style_id(cell.style())) + .map(|cell| style_id(cell.frame_style())) .unwrap_or(0); encode_glyph_instance(&mut replacements, glyph, id); } @@ -459,7 +539,11 @@ pub(crate) fn encode_frame_text_snapshot( } } } - let section_count = 7 + usize::from(links.is_some()) + usize::from(clipboard_payload.is_some()); + let palette_section = style_palette_count > 0; + let section_count = 7 + + usize::from(links.is_some()) + + usize::from(clipboard_payload.is_some()) + + usize::from(palette_section); let glyph_offset = FRAME_HEADER_BYTES + SECTION_HEADER_BYTES * section_count; let style_offset = glyph_offset + glyph_definitions.len(); let replacement_offset = style_offset + style_definitions.len(); @@ -491,6 +575,10 @@ pub(crate) fn encode_frame_text_snapshot( let link_offset = packet.len(); packet.extend_from_slice(&link_payload); + let style_palette_offset = packet.len(); + if palette_section { + packet.extend_from_slice(&style_palette); + } put_u32(&mut packet, 0, FRAME_MAGIC); put_u16(&mut packet, 4, 1); @@ -561,6 +649,15 @@ pub(crate) fn encode_frame_text_snapshot( put_u32(&mut packet, link_header + 8, link_payload.len() as u32); put_u32(&mut packet, link_header + 12, links.len() as u32); } + if palette_section { + let palette_header = 176 + + (usize::from(clipboard_payload.is_some()) + usize::from(links.is_some())) + * SECTION_HEADER_BYTES; + put_u16(&mut packet, palette_header, STYLE_PALETTE); + put_u32(&mut packet, palette_header + 4, style_palette_offset as u32); + put_u32(&mut packet, palette_header + 8, style_palette.len() as u32); + put_u32(&mut packet, palette_header + 12, style_palette_count); + } Ok(packet) } @@ -635,14 +732,14 @@ mod tests { definitions: Vec::new(), }; let mut definitions = HashMap::new(); - definitions.insert(0, CellStyle::default()); + definitions.insert(0, FrameStyle::default()); let prepared = prepare_row_styles(&cells, &shaped, &mut definitions); assert_eq!( prepared.cell_ids, cells .iter() - .map(|cell| style_id(cell.style)) + .map(|cell| style_id(cell.frame_style())) .collect::>() ); for glyph in &shaped.glyphs { @@ -652,7 +749,7 @@ mod tests { glyph.cell_start >= cell.column && glyph.cell_start < cell.column.saturating_add(cell.span) }) - .map(|cell| style_id(cell.style)) + .map(|cell| style_id(cell.frame_style())) .unwrap_or(0); assert_eq!( prepared @@ -667,6 +764,125 @@ mod tests { } } + fn frame_sections(frame: &[u8]) -> Vec<(u16, &[u8], u32)> { + let count = u16::from_le_bytes([frame[60], frame[61]]) as usize; + (0..count) + .map(|index| { + let header = &frame[FRAME_HEADER_BYTES + index * SECTION_HEADER_BYTES..]; + let field = |at: usize| u32::from_le_bytes(header[at..at + 4].try_into().unwrap()); + let (offset, length) = (field(4) as usize, field(8) as usize); + ( + u16::from_le_bytes([header[0], header[1]]), + &frame[offset..offset + length], + field(12), + ) + }) + .collect() + } + + #[test] + fn palette_colors_carry_their_index_beside_the_resolved_rgb() { + let mut terminal = ghosttea_vt::GhosttyTerminalCore::new(8, 1, 0).unwrap(); + terminal.feed(b"\x1b[31;44mA\x1b[0m"); + let palette_red = terminal.snapshot().unwrap().cells[0][0] + .style + .foreground + .unwrap(); + let [r, g, b] = palette_red; + terminal.feed(format!("\x1b[38;2;{r};{g};{b}mB\x1b[0m").as_bytes()); + let snapshot = terminal.snapshot().unwrap(); + let shaped_rows = vec![ShapedRow::default(); snapshot.rows.len()]; + let cursor = FrameCursor { + x: 0, + y: 0, + visible: false, + style: 0, + blinking: false, + }; + let frame = encode_text_snapshot(TextSnapshot { + session_handle: 1, + session_epoch: 1, + layout_epoch: 1, + sequence: 1, + revision: 1, + cols: snapshot.cols, + rows: &snapshot.rows, + shaped_rows: &shaped_rows, + cells: &snapshot.cells, + updated_rows: &[0], + full_snapshot: true, + catalog_reset: false, + mouse_tracking: false, + scrollbar: &snapshot.scrollbar, + selection: None, + new_glyph_definitions: &[], + clipboard: None, + cursor: &cursor, + links: None, + }) + .unwrap(); + + let cells = &snapshot.cells[0]; + let palette_id = style_id(cells[0].frame_style()); + let truecolor_id = style_id(cells[1].frame_style()); + assert_eq!(cells[0].style.foreground, cells[1].style.foreground); + assert_ne!(palette_id, truecolor_id, "same RGB from different sources"); + + let sections = frame_sections(&frame); + let (_, payload, count) = sections + .iter() + .find(|(kind, ..)| *kind == STYLE_PALETTE) + .copied() + .expect("palette section"); + assert_eq!(count, 1); + let mut expected = 1_u32.to_le_bytes().to_vec(); + expected.extend_from_slice(&palette_id.to_le_bytes()); + expected.extend_from_slice(&[0b11, 1, 4, 0]); + assert_eq!(payload, expected); + } + + #[test] + fn truecolor_frames_omit_the_palette_section() { + let mut terminal = ghosttea_vt::GhosttyTerminalCore::new(8, 1, 0).unwrap(); + terminal.feed(b"\x1b[38;2;1;2;3mA"); + let snapshot = terminal.snapshot().unwrap(); + let shaped_rows = vec![ShapedRow::default(); 1]; + let cursor = FrameCursor { + x: 0, + y: 0, + visible: false, + style: 0, + blinking: false, + }; + let frame = encode_text_snapshot(TextSnapshot { + session_handle: 1, + session_epoch: 1, + layout_epoch: 1, + sequence: 1, + revision: 1, + cols: snapshot.cols, + rows: &snapshot.rows, + shaped_rows: &shaped_rows, + cells: &snapshot.cells, + updated_rows: &[0], + full_snapshot: true, + catalog_reset: false, + mouse_tracking: false, + scrollbar: &snapshot.scrollbar, + selection: None, + new_glyph_definitions: &[], + clipboard: None, + cursor: &cursor, + links: None, + }) + .unwrap(); + assert!( + frame_sections(&frame) + .iter() + .all(|(kind, ..)| *kind != STYLE_PALETTE) + ); + } + #[test] fn snapshot_has_expected_header() { let mut engine = ghosttea_text::TextEngine::discover().unwrap(); diff --git a/native/ghosttea/crates/ghosttea-core/src/replica.rs b/native/ghosttea/crates/ghosttea-core/src/replica.rs index 3ad92047..6a6ea0d6 100644 --- a/native/ghosttea/crates/ghosttea-core/src/replica.rs +++ b/native/ghosttea/crates/ghosttea-core/src/replica.rs @@ -10,7 +10,7 @@ use ghosttea_vt::{ CellStyle, TerminalPalette, TerminalScrollbar, TerminalSelection, resolved_palette, }; -use crate::frame::{FrameCell, FrameTextSnapshot, encode_frame_text_snapshot}; +use crate::frame::{FrameCell, FrameTextSnapshot, StylePalette, encode_frame_text_snapshot}; use crate::{ FrameCursor, LogicalCellStyle, LogicalRow, LogicalTerminalPatch, LogicalTerminalSnapshot, TerminalEffect, TerminalRuntime, TerminalUpdate, TextEnginePerformanceSnapshot, @@ -21,6 +21,7 @@ struct ReplicaFrameCell { column: u16, span: u16, style: CellStyle, + palette: StylePalette, } impl FrameCell for ReplicaFrameCell { @@ -35,6 +36,10 @@ impl FrameCell for ReplicaFrameCell { fn style(&self) -> CellStyle { self.style } + + fn palette(&self) -> StylePalette { + self.palette + } } #[derive(Default)] @@ -525,6 +530,16 @@ fn prepare_logical_cells(row: &LogicalRow, palette: &TerminalPalette) -> Vec { ]); }); + it("attaches palette provenance from the style palette section", () => { + const styles = new Uint8Array(4 + 2 * 16); + const styleView = new DataView(styles.buffer); + styleView.setUint32(0, 2, true); + styleView.setUint32(4, 7, true); + styleView.setUint8(10, 1); + styleView.setUint8(11, 1); + styles.set([204, 102, 102, 20, 30, 40], 12); + styleView.setUint32(20, 8, true); + styleView.setUint8(26, 1); + styles.set([204, 102, 102], 28); + const palette = new Uint8Array(4 + 8); + const paletteView = new DataView(palette.buffer); + paletteView.setUint32(0, 1, true); + paletteView.setUint32(4, 7, true); + palette.set([0b01, 1, 0, 0], 8); + const decoded = decodeStyleDefinitions( + { kind: SectionKind.StyleDefinitions, flags: 0, itemCount: 2, bytes: styles }, + { kind: SectionKind.StylePalette, flags: 0, itemCount: 1, bytes: palette }, + ); + expect(decoded[0]).toMatchObject({ foreground: [204, 102, 102], foregroundPalette: 1 }); + expect(decoded[0]).not.toHaveProperty("backgroundPalette"); + expect(decoded[1]).not.toHaveProperty("foregroundPalette"); + expect(decodeStylePalette({ kind: SectionKind.StylePalette, flags: 0, itemCount: 1, bytes: palette })).toEqual([ + { styleId: 7, foreground: 1 }, + ]); + palette[8] = 0b100; + expect(() => + decodeStylePalette({ kind: SectionKind.StylePalette, flags: 0, itemCount: 1, bytes: palette }), + ).toThrow("invalid style palette flags"); + }); + it("decodes native alpha glyph definitions", () => { const bytes = new Uint8Array(4 + 20 + 4); const view = new DataView(bytes.buffer); diff --git a/packages/ghosttea-frame/src/index.ts b/packages/ghosttea-frame/src/index.ts index 4cb152de..70ab6f77 100644 --- a/packages/ghosttea-frame/src/index.ts +++ b/packages/ghosttea-frame/src/index.ts @@ -22,6 +22,7 @@ export enum SectionKind { AccessibilityText = 10, ClipboardWrite = 11, LinkTargets = 12, + StylePalette = 13, } export interface FrameSection { @@ -93,6 +94,16 @@ export interface StyleDefinition { underline: boolean; foreground?: readonly [number, number, number]; background?: readonly [number, number, number]; + /** Palette index `foreground` was resolved from, when it came from the palette. */ + foregroundPalette?: number; + /** Palette index `background` was resolved from, when it came from the palette. */ + backgroundPalette?: number; +} + +export interface StylePaletteEntry { + styleId: number; + foreground?: number; + background?: number; } export interface StyleRun { @@ -275,7 +286,11 @@ export function decodeGlyphDefinitions(section: FrameSection): GlyphDefinition[] return definitions; } -export function decodeStyleDefinitions(section: FrameSection): StyleDefinition[] { +/** + * Decodes style definitions. Pass the frame's optional `StylePalette` section + * to attach palette provenance; the RGB colors are complete without it. + */ +export function decodeStyleDefinitions(section: FrameSection, paletteSection?: FrameSection): StyleDefinition[] { assertRange(section.kind === SectionKind.StyleDefinitions, "wrong style definition section kind"); assertRange(section.bytes.byteLength >= 4, "truncated style definition header"); const view = new DataView(section.bytes.buffer, section.bytes.byteOffset, section.bytes.byteLength); @@ -308,7 +323,38 @@ export function decodeStyleDefinitions(section: FrameSection): StyleDefinition[] : {}), }); } - return styles; + if (!paletteSection) return styles; + const palette = new Map(decodeStylePalette(paletteSection).map((entry) => [entry.styleId, entry])); + return styles.map((style) => { + const entry = palette.get(style.id); + if (!entry) return style; + return { + ...style, + ...(entry.foreground !== undefined && style.foreground ? { foregroundPalette: entry.foreground } : {}), + ...(entry.background !== undefined && style.background ? { backgroundPalette: entry.background } : {}), + }; + }); +} + +export function decodeStylePalette(section: FrameSection): StylePaletteEntry[] { + assertRange(section.kind === SectionKind.StylePalette, "wrong style palette section kind"); + assertRange(section.bytes.byteLength >= 4, "truncated style palette header"); + const view = new DataView(section.bytes.buffer, section.bytes.byteOffset, section.bytes.byteLength); + const count = view.getUint32(0, true); + assertRange(count === section.itemCount, "style palette count mismatch"); + assertRange(section.bytes.byteLength === 4 + count * 8, "invalid style palette length"); + const entries: StylePaletteEntry[] = []; + for (let index = 0; index < count; index += 1) { + const offset = 4 + index * 8; + const flags = view.getUint8(offset + 4); + assertRange((flags & ~0x3) === 0, "invalid style palette flags"); + entries.push({ + styleId: view.getUint32(offset, true), + ...(flags & 1 ? { foreground: view.getUint8(offset + 5) } : {}), + ...(flags & 2 ? { background: view.getUint8(offset + 6) } : {}), + }); + } + return entries; } export function decodeCursorState(section: FrameSection): CursorState { diff --git a/packages/ghosttea-protocol/src/index.test.ts b/packages/ghosttea-protocol/src/index.test.ts index 1e272051..be7fe662 100644 --- a/packages/ghosttea-protocol/src/index.test.ts +++ b/packages/ghosttea-protocol/src/index.test.ts @@ -370,6 +370,20 @@ describe("isServerEvent", () => { config: { ...config, renderer: { ...config.renderer, postProcess: "mystery" } }, }), ).toBe(false); + for (const renderer of [ + { ...config.renderer, lightAdaptation: "sometimes" }, + { ...config.renderer, minimumContrast: 0.5 }, + { ...config.renderer, minimumContrast: Number.NaN }, + ]) { + expect(isServerEvent({ requestId: 0, type: "config-changed", config: { ...config, renderer } })).toBe(false); + } + expect( + isServerEvent({ + requestId: 0, + type: "config-changed", + config: { ...config, renderer: { ...config.renderer, lightAdaptation: "off", minimumContrast: 4.5 } }, + }), + ).toBe(true); expect(isServerEvent({ requestId: 4, type: "config", config: { ...config, schemaVersion: 2 } })).toBe(false); expect( isServerEvent({ diff --git a/packages/ghosttea-protocol/src/index.ts b/packages/ghosttea-protocol/src/index.ts index 1e00b1ee..c73739c5 100644 --- a/packages/ghosttea-protocol/src/index.ts +++ b/packages/ghosttea-protocol/src/index.ts @@ -11,6 +11,8 @@ export type ConfigDiagnosticSeverity = "info" | "warning" | "error"; export type ConfigSupport = "applied" | "parsed" | "unsupported"; export type ConfigSourceKind = "ghostty-default" | "included" | "ghosttea-overlay"; export type RendererPostProcess = "none" | "better-crt"; +/** `ghosttea-light-adaptation`: may a light theme restyle apps that paint their own dark backgrounds. */ +export type LightAdaptation = "auto" | "off"; export interface ConfigCompatibility { ghosttyVersion: string; @@ -63,6 +65,9 @@ export interface RendererConfig { palette?: PaletteConfigEntry[]; backgroundOpacity?: number; backgroundOpacityCells?: boolean; + /** Ghostty `minimum-contrast`: WCAG ratio text keeps against its cell background; 1 is off. */ + minimumContrast?: number; + lightAdaptation?: LightAdaptation; fontSize: number; fontFamilies: string[]; paddingX: [number, number]; @@ -906,6 +911,14 @@ export function isServerEvent(value: unknown): value is ServerEvent { renderer.backgroundOpacity >= 0 && renderer.backgroundOpacity <= 1)) && (renderer.backgroundOpacityCells === undefined || typeof renderer.backgroundOpacityCells === "boolean") && + (renderer.minimumContrast === undefined || + (typeof renderer.minimumContrast === "number" && + Number.isFinite(renderer.minimumContrast) && + renderer.minimumContrast >= 1 && + renderer.minimumContrast <= 21)) && + (renderer.lightAdaptation === undefined || + renderer.lightAdaptation === "auto" || + renderer.lightAdaptation === "off") && (renderer.linkUrl === undefined || typeof renderer.linkUrl === "boolean") && typeof renderer.fontSize === "number" && Number.isFinite(renderer.fontSize) && diff --git a/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts b/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts index 94fb2994..e91b040d 100644 --- a/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts +++ b/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts @@ -25,7 +25,15 @@ describe("Appearance settings draft", () => { opacityCells: true, effects: ["ghosttea:crt"], animation: true, + lightAdaptation: "auto", + minimumContrast: 1, }); + expect( + appearanceDraftFromConfig({ + ...config, + renderer: { ...config.renderer, lightAdaptation: "off", minimumContrast: 4.5 }, + }), + ).toMatchObject({ lightAdaptation: "off", minimumContrast: 4.5 }); }); it("detects a stale sibling draft without relying on object identity", () => { @@ -33,5 +41,7 @@ describe("Appearance settings draft", () => { expect(sameAppearanceDraft(baseline, { ...baseline, effects: [...baseline.effects] })).toBe(true); expect(sameAppearanceDraft(baseline, { ...baseline, opacity: 0.5 })).toBe(false); expect(sameAppearanceDraft(baseline, { ...baseline, effects: [] })).toBe(false); + expect(sameAppearanceDraft(baseline, { ...baseline, lightAdaptation: "off" })).toBe(false); + expect(sameAppearanceDraft(baseline, { ...baseline, minimumContrast: 3 })).toBe(false); }); }); diff --git a/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx b/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx index 6f1b4bb2..f83c03fb 100644 --- a/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx +++ b/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx @@ -13,6 +13,8 @@ export interface AppearanceDraftState { opacityCells: boolean; effects: TerminalShaderEffect[]; animation: boolean; + lightAdaptation: "auto" | "off"; + minimumContrast: number; } export function appearanceDraftFromConfig(config: ConfigSnapshot): AppearanceDraftState { @@ -24,6 +26,8 @@ export function appearanceDraftFromConfig(config: ConfigSnapshot): AppearanceDra config.renderer.shaderEffects?.filter(isGhostteaShaderEffect) ?? (config.renderer.postProcess === "better-crt" ? ["ghosttea:better-crt"] : []), animation: config.renderer.customShaderAnimation ?? false, + lightAdaptation: config.renderer.lightAdaptation ?? "auto", + minimumContrast: config.renderer.minimumContrast ?? 1, }; } @@ -33,6 +37,8 @@ export function sameAppearanceDraft(left: AppearanceDraftState, right: Appearanc left.opacity === right.opacity && left.opacityCells === right.opacityCells && left.animation === right.animation && + left.lightAdaptation === right.lightAdaptation && + left.minimumContrast === right.minimumContrast && left.effects.length === right.effects.length && left.effects.every((effect, index) => effect === right.effects[index]) ); @@ -72,11 +78,13 @@ export function AppearanceSettings({ const [opacityCells, setOpacityCells] = useState(initialAppearance.opacityCells); const [effects, setEffects] = useState(initialAppearance.effects); const [animation, setAnimation] = useState(initialAppearance.animation); + const [lightAdaptation, setLightAdaptation] = useState(initialAppearance.lightAdaptation); + const [minimumContrast, setMinimumContrast] = useState(initialAppearance.minimumContrast); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const appearanceDraft = useMemo( - () => ({ themeName, opacity, opacityCells, effects, animation }), - [animation, effects, opacity, opacityCells, themeName], + () => ({ themeName, opacity, opacityCells, effects, animation, lightAdaptation, minimumContrast }), + [animation, effects, lightAdaptation, minimumContrast, opacity, opacityCells, themeName], ); const appearanceDirty = !sameAppearanceDraft(appearanceDraft, appearanceBaseline); const appearanceStale = appearanceDirty && observedConfig.revision !== appearanceBaselineRevision; @@ -94,6 +102,8 @@ export function AppearanceSettings({ setOpacityCells(next.opacityCells); setEffects(next.effects); setAnimation(next.animation); + setLightAdaptation(next.lightAdaptation); + setMinimumContrast(next.minimumContrast); setAppearanceBaseline(next); setAppearanceBaselineRevision(nextConfig.revision); }, []); @@ -175,6 +185,8 @@ export function AppearanceSettings({ backgroundOpacityCells: opacityCells, shaderEffects: effects, shaderAnimation: animation, + lightAdaptation, + minimumContrast, }); requestClose(); } catch (cause) { @@ -344,6 +356,33 @@ export function AppearanceSettings({

)} +

Readability

+ + +

+ Text below this ratio against its background is lightened or darkened, keeping its hue. 4.5:1 is the + WCAG AA level for body text. +

+

Shader stack

Effects run top-to-bottom. Animated effects repaint while this window is focused. diff --git a/packages/ghosttea-react/src/appearance/types.ts b/packages/ghosttea-react/src/appearance/types.ts index 404f3924..b1c942c2 100644 --- a/packages/ghosttea-react/src/appearance/types.ts +++ b/packages/ghosttea-react/src/appearance/types.ts @@ -21,6 +21,10 @@ export interface GhostteaAppearanceUpdate { backgroundOpacityCells: boolean; shaderEffects: TerminalShaderEffect[]; shaderAnimation: boolean; + /** `ghosttea-light-adaptation`; omitted by hosts that predate the setting. */ + lightAdaptation?: "auto" | "off"; + /** Ghostty `minimum-contrast` ratio (1 is off). */ + minimumContrast?: number; } export interface GhostteaConfigEditorState { diff --git a/packages/ghosttea-react/src/config.test.ts b/packages/ghosttea-react/src/config.test.ts index 0bb73277..9bb8d945 100644 --- a/packages/ghosttea-react/src/config.test.ts +++ b/packages/ghosttea-react/src/config.test.ts @@ -28,7 +28,11 @@ describe("shared configuration presentation", () => { selectionForeground: [7 / 255, 8 / 255, 9 / 255, 1], backgroundOpacityCells: false, lightAdaptation: "auto", + minimumContrast: 1, }); + expect( + terminalThemeFromConfig({ renderer: { ...renderer, lightAdaptation: "off", minimumContrast: 4.5 } }), + ).toMatchObject({ lightAdaptation: "off", minimumContrast: 4.5 }); expect(terminalEffectsFromConfig({ renderer })).toEqual({ postProcess: "better-crt", shaderEffects: ["ghosttea:better-crt"], diff --git a/packages/ghosttea-react/src/config.ts b/packages/ghosttea-react/src/config.ts index ee63c1b2..6ec2aa9b 100644 --- a/packages/ghosttea-react/src/config.ts +++ b/packages/ghosttea-react/src/config.ts @@ -23,7 +23,8 @@ export function terminalThemeFromConfig(config: Pick selection: rgba(renderer.selectionBackground), selectionForeground: rgba(renderer.selectionForeground), backgroundOpacityCells: renderer.backgroundOpacityCells ?? false, - lightAdaptation: "auto", + lightAdaptation: renderer.lightAdaptation ?? "auto", + minimumContrast: renderer.minimumContrast ?? 1, }; } diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.test.ts b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts index 0f89eb71..19840b99 100644 --- a/packages/ghosttea-react/src/renderers/light-adaptation.test.ts +++ b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts @@ -1,6 +1,12 @@ import type { StyleDefinition, StyleRun } from "@vibecook/ghosttea-frame"; import { describe, expect, it } from "vitest"; -import { createLightAdaptation, darkPaintedBase, isLightBackground, type Rgb } from "./light-adaptation.js"; +import { + createLightAdaptation, + darkPaintedBase, + ensureContrast, + isLightBackground, + type Rgb, +} from "./light-adaptation.js"; import { DEFAULT_THEME, type RenderView, type Rgba, type TerminalTheme } from "./types.js"; const rgba = ([r, g, b]: Rgb): Rgba => [r / 255, g / 255, b / 255, 1]; @@ -37,12 +43,59 @@ describe("light adaptation colors", () => { expect(r - Math.min(g, b)).toBeGreaterThan(20); }); + it("keeps a light theme's own ANSI colors when they stay legible", () => { + const red: Rgb = [0xd2, 0x0f, 0x39]; // Catppuccin Latte red, designed for light paper + const latte = createLightAdaptation([0x1e, 0x1e, 0x2e], rgba([0xef, 0xf1, 0xf5]), rgba([0x4c, 0x4f, 0x69])); + expect(bytes(latte.ink(red, latte.base, 1))).toEqual(red); + // The same RGB as truecolor is an application color and is remapped. + expect(bytes(latte.ink(red, latte.base))).not.toEqual(red); + // Neutral entries describe a role, not a hue: "white" text still darkens. + const white = bytes(latte.ink([0xbc, 0xc0, 0xcc], latte.base, 7)); + expect(white[0]).toBeLessThan(0x80); + }); + it("classifies backgrounds the same way the VT shim answers CSI ? 996 n", () => { expect(isLightBackground([0xef, 0xf1, 0xf5])).toBe(true); expect(isLightBackground([0x1e, 0x1e, 0x2e])).toBe(false); }); }); +describe("minimum contrast", () => { + const luminance = (color: Rgba): number => + [0.2126, 0.7152, 0.0722].reduce((sum, weight, index) => { + const v = color[index]!; + return sum + weight * (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + }, 0); + const ratio = (a: Rgba, b: Rgba): number => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; + return (hi + 0.05) / (lo + 0.05); + }; + const panel = rgba([0xe6, 0xe6, 0xe6]); + + it("leaves text alone when it is off or already meets the ratio", () => { + const gray = rgba([0x9a, 0x9a, 0x9a]); + expect(ensureContrast(gray, panel, 1)).toBe(gray); + const ink = rgba([0x26, 0x26, 0x26]); + expect(ensureContrast(ink, panel, 4.5)).toBe(ink); + }); + + it("moves lightness only as far as the ratio needs and keeps the hue", () => { + const amber: Rgba = [0xe0 / 255, 0xaf / 255, 0x68 / 255, 0.55]; + const result = ensureContrast(amber, panel, 4.5); + expect(ratio(result, panel)).toBeGreaterThanOrEqual(4.5); + expect(ratio(result, panel)).toBeLessThan(4.7); + const [r, g, b] = bytes(result); + expect(r).toBeGreaterThan(g); + expect(g).toBeGreaterThan(b); + expect(result[3]).toBe(0.55); + }); + + it("reaches black or white when nothing less suffices", () => { + expect(bytes(ensureContrast(rgba([0x80, 0x80, 0x80]), rgba([0x80, 0x80, 0x80]), 21))).toEqual([0, 0, 0]); + expect(bytes(ensureContrast(rgba([0x30, 0x30, 0x30]), rgba([0x20, 0x20, 0x20]), 7))[0]).toBeGreaterThan(0x80); + }); +}); + function view(theme: TerminalTheme, cols: number, rows: number, paintedRows: number): RenderView { const style: StyleDefinition = { id: 7, diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.ts b/packages/ghosttea-react/src/renderers/light-adaptation.ts index 39ec5e98..b4e164fc 100644 --- a/packages/ghosttea-react/src/renderers/light-adaptation.ts +++ b/packages/ghosttea-react/src/renderers/light-adaptation.ts @@ -15,7 +15,12 @@ // accents land in a mid-tone band: APCA Lc = clamp(0.5·|Lc| + 28, 45, 70) // against the new background, chroma raised toward 0.12. // -// Theme-owned colors (default fg/bg) are left to the theme. +// Theme-owned colors (default fg/bg) are left to the theme, and so are +// chromatic ANSI colors (palette 1–6, 9–14): the light theme designed them for +// light paper, so they pass through unless the remapped surface beneath makes +// them less legible than on the theme background. Neutral ANSI colors (0, 7, +// 8, 15) carry a role — "bright text", "dim panel" — rather than a hue, so +// they remap like truecolor. import type { StyleDefinition } from "@vibecook/ghosttea-frame"; import type { RenderView, Rgba } from "./types.js"; @@ -110,12 +115,6 @@ const smoothstep = (e0: number, e1: number, x: number): number => { return t * t * (3 - 2 * t); }; -/** Light when dark text out-contrasts light text: WCAG luminance above ~0.179. Matches the VT shim. */ -export function isLightBackground([r, g, b]: Rgb): boolean { - const luminance = 0.2126 * toLinear(r / 255) + 0.7152 * toLinear(g / 255) + 0.0722 * toLinear(b / 255); - return (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05); -} - const toRgba = ([r, g, b]: Rgb): Rgba => [r / 255, g / 255, b / 255, 1]; const fromRgba = (color: Rgba): Rgb => [ Math.round(clamp(color[0]) * 255), @@ -124,14 +123,75 @@ const fromRgba = (color: Rgba): Rgb => [ ]; const keyOf = ([r, g, b]: Rgb): number => (r << 16) | (g << 8) | b; +const luminance = ([r, g, b]: Rgb): number => + 0.2126 * toLinear(r / 255) + 0.7152 * toLinear(g / 255) + 0.0722 * toLinear(b / 255); +const contrastRatio = (a: Rgb, b: Rgb): number => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; + return (hi + 0.05) / (lo + 0.05); +}; + +/** Light when dark text out-contrasts light text: WCAG luminance above ~0.179. Matches the VT shim. */ +export function isLightBackground(color: Rgb): boolean { + const y = luminance(color); + return (y + 0.05) / 0.05 > 1.05 / (y + 0.05); +} + +/** null: the text already meets the ratio. */ +const contrasted = new Map(); + +/** + * Ghostty's `minimum-contrast`: text below the WCAG ratio against its + * background moves toward whichever of black or white contrasts more. Ghostty + * snaps straight to that extreme; this keeps hue and chroma and moves OKLab + * lightness only as far as the ratio needs, reaching black or white only when + * nothing less suffices. + */ +export function ensureContrast(text: Rgba, background: Rgba, ratio: number): Rgba { + if (!(ratio > 1)) return text; + const fg = fromRgba(text); + const bg = fromRgba(background); + const cacheKey = `${keyOf(fg)}/${keyOf(bg)}/${ratio}`; + let result = contrasted.get(cacheKey); + if (result === undefined) { + if (contrastRatio(fg, bg) >= ratio) result = null; + else { + const towardWhite = contrastRatio([255, 255, 255], bg) > contrastRatio([0, 0, 0], bg); + const [L, a, b] = oklab(fg); + const C = Math.hypot(a, b); + const h = Math.atan2(b, a); + let near = L; + let far = towardWhite ? 1 : 0; + let best: Rgb = towardWhite ? [255, 255, 255] : [0, 0, 0]; + if (contrastRatio(best, bg) >= ratio) { + for (let i = 0; i < 20; i += 1) { + const mid = (near + far) / 2; + const candidate = gamutMap(mid, C, h); + if (contrastRatio(candidate, bg) >= ratio) { + far = mid; + best = candidate; + } else near = mid; + } + } + result = best; + } + if (contrasted.size > 4096) contrasted.clear(); + contrasted.set(cacheKey, result); + } + if (result === null) return text; + return [result[0] / 255, result[1] / 255, result[2] / 255, text[3]]; +} + export interface LightAdaptation { /** Stable identity for cache keys: base, paper, and ink. */ readonly key: string; readonly base: Rgb; /** A color painted as an area: cell backgrounds and block-element art. */ surface(color: Rgb): Rgba; - /** A glyph color, judged against the background it was designed on. */ - ink(color: Rgb, sourceBackground: Rgb): Rgba; + /** + * A glyph color, judged against the background it was designed on. + * `paletteIndex` is the ANSI entry the color came from, when known. + */ + ink(color: Rgb, sourceBackground: Rgb, paletteIndex?: number): Rgba; } export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgba): LightAdaptation { @@ -181,11 +241,20 @@ export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgb }; const inks = new Map(); - const ink = (color: Rgb, sourceBackground: Rgb): Rgba => { - const cacheKey = `${keyOf(color)}/${keyOf(sourceBackground)}`; + const ink = (color: Rgb, sourceBackground: Rgb, paletteIndex?: number): Rgba => { + const themeColor = paletteIndex !== undefined && CHROMATIC_ANSI.has(paletteIndex); + const cacheKey = `${keyOf(color)}/${keyOf(sourceBackground)}${themeColor ? "/p" : ""}`; const cached = inks.get(cacheKey); if (cached) return cached; const mapped = surfaceRgb(sourceBackground); + if (themeColor) { + const legibility = Math.min(45, Math.abs(apca(color, paper))); + if (Math.abs(apca(color, mapped)) >= legibility) { + const rgba = toRgba(color); + inks.set(cacheKey, rgba); + return rgba; + } + } const [L, a0, b0] = oklab(color); const C0 = Math.hypot(a0, b0); // Neutral ink: mirror its Lr distance from the background, and lean @@ -230,6 +299,8 @@ export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgb }; } +const CHROMATIC_ANSI = new Set([1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14]); + /** Engage when this share of cells carries an explicit dark background… */ export const ENGAGE_SHARE = 0.6; /** …and stay engaged until it drops below this, so scrolling content does not flicker. */ diff --git a/packages/ghosttea-react/src/renderers/types.ts b/packages/ghosttea-react/src/renderers/types.ts index 30be77d6..0bddc47e 100644 --- a/packages/ghosttea-react/src/renderers/types.ts +++ b/packages/ghosttea-react/src/renderers/types.ts @@ -25,6 +25,12 @@ export interface TerminalTheme { * engages per pane only when most cells carry an explicit dark background. */ lightAdaptation?: "auto" | "off"; + /** + * Ghostty `minimum-contrast`: the WCAG ratio text keeps against its cell + * background (1–21; 1 is off). Box drawing, block elements, and color + * glyphs are exempt. + */ + minimumContrast?: number; } export type TerminalPostProcess = "none" | "better-crt"; diff --git a/packages/ghosttea-react/src/renderers/webgpu-renderer.ts b/packages/ghosttea-react/src/renderers/webgpu-renderer.ts index c2575118..25cb2461 100644 --- a/packages/ghosttea-react/src/renderers/webgpu-renderer.ts +++ b/packages/ghosttea-react/src/renderers/webgpu-renderer.ts @@ -21,7 +21,7 @@ import { graphemeCellWidth, splitGraphemes } from "../cell-width.js"; import { backgroundRunBounds } from "./background-geometry.js"; import { rowsForDamage } from "./render-damage.js"; import { SHADER_EFFECT_WGSL } from "./shader-effects.js"; -import { createLightAdaptation, darkPaintedBase, type LightAdaptation } from "./light-adaptation.js"; +import { createLightAdaptation, darkPaintedBase, ensureContrast, type LightAdaptation } from "./light-adaptation.js"; const ATLAS_SIZE = 2048; const GEOMETRY_CACHE_LIMIT = 8; @@ -571,6 +571,7 @@ function geometryCacheKey( ...view.theme.selectionForeground, ].join(","), view.theme.backgroundOpacityCells ? "opacity-cells" : "opaque-cells", + view.theme.minimumContrast ?? 1, adaptationKey, selection, effectiveCursorStyle(view) === CursorStyle.Block ? `${view.cursor.x},${view.cursor.y}` : "-", @@ -856,6 +857,8 @@ interface ResolvedStyle { background: Rgba | null; /** Foreground used as an area fill (block elements); differs only under light adaptation. */ fill: Rgba; + /** Foreground for text glyphs and their decorations, after `minimum-contrast`. */ + text: Rgba; underline: boolean; strikethrough: boolean; invisible: boolean; @@ -876,12 +879,12 @@ function resolveStyle( if (style?.inverse) { background = source.foreground ? adaptation.surface(source.foreground) : theme.foreground; foreground = source.background - ? adaptation.ink(source.background, source.foreground ?? adaptation.base) + ? adaptation.ink(source.background, source.foreground ?? adaptation.base, style?.backgroundPalette) : theme.background; fill = foreground; } else { foreground = source.foreground - ? adaptation.ink(source.foreground, source.background ?? adaptation.base) + ? adaptation.ink(source.foreground, source.background ?? adaptation.base, style?.foregroundPalette) : theme.foreground; background = source.background ? adaptation.surface(source.background) : null; fill = source.foreground ? adaptation.surface(source.foreground) : theme.foreground; @@ -895,14 +898,23 @@ function resolveStyle( if (background && theme.backgroundOpacityCells) { background = [background[0], background[1], background[2], theme.background[3]]; } + // Graphics (box drawing, block elements) and color glyphs keep the raw + // foreground, as in Ghostty; the backdrop ignores window transparency. + let text = ensureContrast( + foreground, + over(background ?? theme.background, theme.background), + theme.minimumContrast ?? 1, + ); if (style?.faint) { foreground = [foreground[0], foreground[1], foreground[2], foreground[3] * 0.55]; fill = [fill[0], fill[1], fill[2], fill[3] * 0.55]; + text = [text[0], text[1], text[2], text[3] * 0.55]; } return { foreground, background, fill, + text, underline: style?.underline ?? false, strikethrough: style?.strikethrough ?? false, invisible: style?.invisible ?? false, @@ -1672,8 +1684,9 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { ) continue; } - const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas; - const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices; + const mono = definition.format === GlyphFormat.Alpha8; + const atlas = mono ? this.#monoAtlas : this.#colorAtlas; + const vertices = mono ? glyphVertices : colorGlyphVertices; pushGlyph( vertices, (ORIGIN_X + instance.x) * scale, @@ -1681,7 +1694,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { instance.width * scale, instance.height * scale, atlas.glyph(definition), - foreground, + mono && foreground === style.foreground ? style.text : foreground, viewportWidth, viewportHeight, ); @@ -1731,7 +1744,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { Math.round(rowTop + 16 * scale), width, stroke, - style.foreground, + style.text, viewportWidth, viewportHeight, ); @@ -1743,7 +1756,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { Math.round(rowTop + 9 * scale), width, stroke, - style.foreground, + style.text, viewportWidth, viewportHeight, ); @@ -1932,8 +1945,9 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { ) continue; } - const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas; - const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices; + const mono = definition.format === GlyphFormat.Alpha8; + const atlas = mono ? this.#monoAtlas : this.#colorAtlas; + const vertices = mono ? glyphVertices : colorGlyphVertices; pushGlyph( vertices, (ORIGIN_X + instance.x) * scale, @@ -1941,7 +1955,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { instance.width * scale, instance.height * scale, atlas.glyph(definition), - foreground, + mono && foreground === style.foreground ? style.text : foreground, viewportWidth, viewportHeight, ); @@ -1991,7 +2005,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { Math.round(rowTop + 16 * scale), width, stroke, - style.foreground, + style.text, viewportWidth, viewportHeight, ); @@ -2003,7 +2017,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { Math.round(rowTop + 9 * scale), width, stroke, - style.foreground, + style.text, viewportWidth, viewportHeight, ); diff --git a/packages/ghosttea-react/src/terminal-render.worker.ts b/packages/ghosttea-react/src/terminal-render.worker.ts index 824a37b4..459a46c4 100644 --- a/packages/ghosttea-react/src/terminal-render.worker.ts +++ b/packages/ghosttea-react/src/terminal-render.worker.ts @@ -830,7 +830,8 @@ function applyFrame( } const glyphDefinitions = glyphSection ? decodeGlyphDefinitions(glyphSection) : NO_GLYPH_DEFINITIONS; - const styleDefinitions = styleSection ? decodeStyleDefinitions(styleSection) : NO_STYLE_DEFINITIONS; + const paletteSection = frame.sections.find((candidate) => candidate.kind === SectionKind.StylePalette); + const styleDefinitions = styleSection ? decodeStyleDefinitions(styleSection, paletteSection) : NO_STYLE_DEFINITIONS; const replacements = decodeRowReplacements(rowSection); for (const replacement of replacements) { if (replacement.row >= frame.rows) throw new RangeError("Row replacement exceeds viewport"); From 4342f2aeccc0ce2282a943bbb07133c5ddb5e637 Mon Sep 17 00:00:00 2001 From: James Yong Date: Fri, 2 Oct 2026 22:54:40 -0700 Subject: [PATCH 2/2] feat(ios): light adaptation, minimum-contrast, and palette provenance in Metal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the WebGPU remap to GhostteaMetalRenderer: the TRF1 decoder reads section 13, the theme carries the two new settings from the presentation config, styles resolve once per mesh with text/fill colors, and the adaptation key joins the geometry and row-cache keys. Settings → Appearance gains a Readability section that writes the same keys as the desktop. Verified in the iOS simulator against a core built from this branch; the published Apple native artifact needs its next rebuild before devices see section 13 or the new keys. Co-Authored-By: Claude Opus 5.5 --- apple/GhostteaApp/App/GhostteaAppModel.swift | 2 + .../GhostteaApp/App/GhostteaSSHAppModel.swift | 2 + .../App/GhostteaSettingsView.swift | 34 ++ .../GhostteaAppearance.swift | 29 +- .../GhostteaCore/GhostteaConfiguration.swift | 27 + .../Sources/GhostteaFrame/TRF1Decoder.swift | 97 +++- .../GhostteaLightAdaptation.swift | 483 ++++++++++++++++++ .../GhostteaMetalRenderer.swift | 230 ++++++++- .../GhostteaTerminalFrameDecoder.swift | 4 +- .../GhostteaTerminalMetalView.swift | 5 +- .../GhostteaTerminal/RetainedTRF1State.swift | 5 +- .../GhostteaAppearanceTests.swift | 30 ++ .../GhostteaConfigurationTests.swift | 43 +- .../GhostteaLightAdaptationTests.swift | 314 ++++++++++++ .../GhostteaFrameTests/TRF1DecoderTests.swift | 125 +++++ .../Tools/GhostteaTRF1FuzzProbe/main.swift | 2 + 16 files changed, 1405 insertions(+), 27 deletions(-) create mode 100644 apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaLightAdaptation.swift create mode 100644 apple/GhostteaKit/Tests/GhostteaFrameTests/GhostteaLightAdaptationTests.swift diff --git a/apple/GhostteaApp/App/GhostteaAppModel.swift b/apple/GhostteaApp/App/GhostteaAppModel.swift index a9d50b49..3ea77a51 100644 --- a/apple/GhostteaApp/App/GhostteaAppModel.swift +++ b/apple/GhostteaApp/App/GhostteaAppModel.swift @@ -965,6 +965,8 @@ extension GhostteaTerminalPresentationConfig { && palette == other.palette && backgroundOpacity == other.backgroundOpacity && backgroundOpacityCells == other.backgroundOpacityCells + && minimumContrast == other.minimumContrast + && lightAdaptation == other.lightAdaptation && fontSize == other.fontSize && fontFamilies == other.fontFamilies && paddingX == other.paddingX diff --git a/apple/GhostteaApp/App/GhostteaSSHAppModel.swift b/apple/GhostteaApp/App/GhostteaSSHAppModel.swift index 4ee5f833..e362a240 100644 --- a/apple/GhostteaApp/App/GhostteaSSHAppModel.swift +++ b/apple/GhostteaApp/App/GhostteaSSHAppModel.swift @@ -1408,6 +1408,8 @@ extension GhostteaTerminalPresentationConfig { palette: palette, backgroundOpacity: backgroundOpacity, backgroundOpacityCells: backgroundOpacityCells, + minimumContrast: minimumContrast, + lightAdaptation: lightAdaptation, fontSize: runtime.fontSize, fontFamilies: runtime.fontFamilies, paddingX: paddingX, diff --git a/apple/GhostteaApp/App/GhostteaSettingsView.swift b/apple/GhostteaApp/App/GhostteaSettingsView.swift index 8b4dd720..27f190dc 100644 --- a/apple/GhostteaApp/App/GhostteaSettingsView.swift +++ b/apple/GhostteaApp/App/GhostteaSettingsView.swift @@ -135,6 +135,29 @@ private struct GhostteaAppearanceSettingsView: View { .foregroundStyle(.secondary) } + Section("Readability") { + Toggle( + "Restyle dark apps under light themes", + isOn: Binding( + get: { configuration.appearance.lightAdaptation == .auto }, + set: { value in update { $0.lightAdaptation = value ? .auto : .off } })) + Picker( + "Minimum text contrast", + selection: Binding( + get: { configuration.appearance.minimumContrast }, + set: { value in update { $0.minimumContrast = value } }) + ) { + ForEach(contrastChoices, id: \.self) { ratio in + Text(contrastLabel(ratio)).tag(ratio) + } + } + Text( + "Apps that paint their own dark background are redrawn as a light design when a light theme is active. Text below the minimum contrast against its background is lightened or darkened, keeping its hue; 4.5:1 is the WCAG AA level for body text." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + if !configuration.appearance.shaderEffects.isEmpty { Section("Shader order") { ForEach(configuration.appearance.shaderEffects, id: \.self) { id in @@ -201,6 +224,17 @@ private struct GhostteaAppearanceSettingsView: View { configuration.applyAppearance(selection) } + /// The offered ratios, plus a hand-edited one so the picker can show it. + private var contrastChoices: [Double] { + let current = configuration.appearance.minimumContrast + let choices = GhostteaAppearanceSelection.minimumContrastChoices + return choices.contains(current) ? choices : (choices + [current]).sorted() + } + + private func contrastLabel(_ ratio: Double) -> String { + ratio > 1 ? "\(ratio.formatted(.number.precision(.fractionLength(0...2)))):1" : "Off" + } + private func shaderName(_ id: String) -> String { GhostteaShaderOption.available.first { $0.id == id }?.name ?? id } diff --git a/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift b/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift index a49ae55c..aa885556 100644 --- a/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift +++ b/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift @@ -88,19 +88,30 @@ public struct GhostteaAppearanceSelection: Equatable, Sendable { public var backgroundOpacityCells: Bool public var shaderEffects: [String] public var shaderAnimation: Bool + /// `ghosttea-light-adaptation`: restyle dark-painted apps under light themes. + public var lightAdaptation: GhostteaLightAdaptation + /// Ghostty `minimum-contrast` ratio (1 is off). + public var minimumContrast: Double + + /// Ratios offered by Settings; 4.5:1 is the WCAG AA level for body text. + public static let minimumContrastChoices: [Double] = [1, 3, 4.5, 7] public init( theme: GhostteaColorTheme? = nil, backgroundOpacity: Double = 1, backgroundOpacityCells: Bool = false, shaderEffects: [String] = [], - shaderAnimation: Bool = false + shaderAnimation: Bool = false, + lightAdaptation: GhostteaLightAdaptation = .auto, + minimumContrast: Double = 1 ) { self.theme = theme self.backgroundOpacity = backgroundOpacity self.backgroundOpacityCells = backgroundOpacityCells self.shaderEffects = shaderEffects self.shaderAnimation = shaderAnimation + self.lightAdaptation = lightAdaptation + self.minimumContrast = minimumContrast } public init(config: GhostteaConfigSnapshot) { @@ -112,6 +123,8 @@ public struct GhostteaAppearanceSelection: Equatable, Sendable { ? [GhostteaShaderEffect.betterCRT.rawValue] : config.renderer.shaderEffects shaderAnimation = config.renderer.customShaderAnimation + lightAdaptation = config.renderer.lightAdaptation + minimumContrast = Double(config.renderer.minimumContrast) } } @@ -237,6 +250,8 @@ public enum GhostteaConfigurationDraft { public static func appearanceBlock(_ selection: GhostteaAppearanceSelection) throws -> String { guard selection.backgroundOpacity.isFinite, (0...1).contains(selection.backgroundOpacity), + selection.minimumContrast.isFinite, + (1...21).contains(selection.minimumContrast), selection.shaderEffects.allSatisfy({ id in GhostteaShaderOption.available.contains { $0.id == id } }) @@ -268,6 +283,8 @@ public enum GhostteaConfigurationDraft { } lines += [ "custom-shader-animation = \(selection.shaderAnimation)", + "ghosttea-light-adaptation = \(selection.lightAdaptation.rawValue)", + "minimum-contrast = \(contrastRatio(selection.minimumContrast))", appearanceBlockEnd, ] return lines.joined(separator: "\n") @@ -434,6 +451,7 @@ public enum GhostteaConfigurationDraft { lines.append("custom-shader =") lines += config.renderer.shaderEffects.map { "custom-shader = \($0)" } lines.append("custom-shader-animation = \(config.renderer.customShaderAnimation)") + lines.append("minimum-contrast = \(contrastRatio(Double(config.renderer.minimumContrast)))") lines += config.renderer.palette.map { entry in "palette = \(entry.index)=\(GhostteaFriendlyConfigValues.hex(entry.color))" } @@ -514,6 +532,15 @@ public enum GhostteaConfigurationDraft { String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), value) } + /// Up to two decimals without trailing zeros, matching the desktop's + /// `minimum-contrast = 4.5` rather than `4.50`. + private static func contrastRatio(_ value: Double) -> String { + var text = String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), value) + while text.hasSuffix("0") { text.removeLast() } + if text.hasSuffix(".") { text.removeLast() } + return text + } + private static func isColor(_ value: String) -> Bool { value.range(of: "^#[0-9a-fA-F]{6}$", options: .regularExpression) != nil } diff --git a/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift b/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift index 5c2e12bb..1a192a00 100644 --- a/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift +++ b/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift @@ -24,6 +24,13 @@ public enum GhostteaRendererPostProcess: String, Codable, Sendable { case betterCRT = "better-crt" } +/// Whether a light theme may restyle applications that paint their own dark +/// backgrounds (`ghosttea-light-adaptation`). +public enum GhostteaLightAdaptation: String, Codable, Sendable { + case auto + case off +} + public enum GhostteaShaderEffect: String, CaseIterable, Codable, Sendable { case betterCRT = "ghosttea:better-crt" case crt = "ghosttea:crt" @@ -113,6 +120,9 @@ public struct GhostteaResolvedRendererConfig: Codable, Equatable, Sendable { public let palette: [GhostteaPaletteConfigEntry] public let backgroundOpacity: Float public let backgroundOpacityCells: Bool + /// WCAG contrast ratio text keeps against its cell background (1 = off). + public let minimumContrast: Float + public let lightAdaptation: GhostteaLightAdaptation public let fontSize: Float public let fontFamilies: [String] public let paddingX: [Float] @@ -125,6 +135,7 @@ public struct GhostteaResolvedRendererConfig: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case foreground, background, cursor, cursorText, selectionBackground, selectionForeground case palette, backgroundOpacity, backgroundOpacityCells + case minimumContrast, lightAdaptation case fontSize, fontFamilies, paddingX, paddingY, postProcess case shaderEffects, customShaderAnimation, customShaderPaths } @@ -141,6 +152,9 @@ public struct GhostteaResolvedRendererConfig: Codable, Equatable, Sendable { backgroundOpacity = try values.decodeIfPresent(Float.self, forKey: .backgroundOpacity) ?? 1 backgroundOpacityCells = try values.decodeIfPresent(Bool.self, forKey: .backgroundOpacityCells) ?? false + minimumContrast = try values.decodeIfPresent(Float.self, forKey: .minimumContrast) ?? 1 + lightAdaptation = + try values.decodeIfPresent(GhostteaLightAdaptation.self, forKey: .lightAdaptation) ?? .auto fontSize = try values.decode(Float.self, forKey: .fontSize) fontFamilies = try values.decode([String].self, forKey: .fontFamilies) paddingX = try values.decode([Float].self, forKey: .paddingX) @@ -169,6 +183,9 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable { public let palette: [GhostteaPaletteConfigEntry] public let backgroundOpacity: Float public let backgroundOpacityCells: Bool + /// WCAG contrast ratio text keeps against its cell background (1 = off). + public let minimumContrast: Float + public let lightAdaptation: GhostteaLightAdaptation public let fontSize: Float public let fontFamilies: [String] public let paddingX: [Float] @@ -191,6 +208,8 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable { palette: [GhostteaPaletteConfigEntry] = [], backgroundOpacity: Float = 1, backgroundOpacityCells: Bool = false, + minimumContrast: Float = 1, + lightAdaptation: GhostteaLightAdaptation = .auto, fontSize: Float, fontFamilies: [String], paddingX: [Float], @@ -211,6 +230,8 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable { self.palette = palette self.backgroundOpacity = backgroundOpacity self.backgroundOpacityCells = backgroundOpacityCells + self.minimumContrast = minimumContrast + self.lightAdaptation = lightAdaptation self.fontSize = fontSize self.fontFamilies = fontFamilies self.paddingX = paddingX @@ -225,6 +246,7 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable { case schemaVersion, revision, foreground, background, cursor, cursorText case selectionBackground, selectionForeground, palette case backgroundOpacity, backgroundOpacityCells + case minimumContrast, lightAdaptation case fontSize, fontFamilies, paddingX, paddingY, postProcess case shaderEffects, customShaderAnimation, customShaderCount } @@ -243,6 +265,9 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable { backgroundOpacity = try values.decodeIfPresent(Float.self, forKey: .backgroundOpacity) ?? 1 backgroundOpacityCells = try values.decodeIfPresent(Bool.self, forKey: .backgroundOpacityCells) ?? false + minimumContrast = try values.decodeIfPresent(Float.self, forKey: .minimumContrast) ?? 1 + lightAdaptation = + try values.decodeIfPresent(GhostteaLightAdaptation.self, forKey: .lightAdaptation) ?? .auto fontSize = try values.decode(Float.self, forKey: .fontSize) fontFamilies = try values.decode([String].self, forKey: .fontFamilies) paddingX = try values.decode([Float].self, forKey: .paddingX) @@ -310,6 +335,8 @@ public struct GhostteaConfigSnapshot: Codable, Equatable, Sendable { palette: renderer.palette, backgroundOpacity: renderer.backgroundOpacity, backgroundOpacityCells: renderer.backgroundOpacityCells, + minimumContrast: renderer.minimumContrast, + lightAdaptation: renderer.lightAdaptation, fontSize: renderer.fontSize, fontFamilies: renderer.fontFamilies, paddingX: renderer.paddingX, diff --git a/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift b/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift index 582a3eca..b909f203 100644 --- a/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift +++ b/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift @@ -37,6 +37,7 @@ public struct TRF1SectionKind: RawRepresentable, Hashable, Sendable { public static let viewportMetadata = Self(rawValue: 9) public static let accessibilityText = Self(rawValue: 10) public static let clipboardWrite = Self(rawValue: 11) + public static let stylePalette = Self(rawValue: 13) } public struct TRF1Section: Sendable { @@ -122,6 +123,45 @@ public struct TRF1StyleDefinition: Equatable, Sendable { public let underline: Bool public let foreground: TRF1RGB? public let background: TRF1RGB? + /// Palette index `foreground` was resolved from, when it came from the palette. + public let foregroundPalette: UInt8? + /// Palette index `background` was resolved from, when it came from the palette. + public let backgroundPalette: UInt8? + + public init( + id: UInt32, + bold: Bool, + italic: Bool, + faint: Bool, + inverse: Bool, + invisible: Bool, + strikethrough: Bool, + underline: Bool, + foreground: TRF1RGB?, + background: TRF1RGB?, + foregroundPalette: UInt8? = nil, + backgroundPalette: UInt8? = nil + ) { + self.id = id + self.bold = bold + self.italic = italic + self.faint = faint + self.inverse = inverse + self.invisible = invisible + self.strikethrough = strikethrough + self.underline = underline + self.foreground = foreground + self.background = background + self.foregroundPalette = foregroundPalette + self.backgroundPalette = backgroundPalette + } +} + +/// Palette provenance for one style definition (TRF1 section 13). +public struct TRF1StylePaletteEntry: Equatable, Sendable { + public let styleID: UInt32 + public let foreground: UInt8? + public let background: UInt8? } public enum TRF1CursorStyle: UInt8, Sendable { @@ -421,7 +461,12 @@ public func decodeTRF1GlyphDefinitions(_ section: TRF1Section) throws -> [TRF1Gl return definitions } -public func decodeTRF1StyleDefinitions(_ section: TRF1Section) throws -> [TRF1StyleDefinition] { +/// Decodes style definitions. Pass the frame's optional style palette section +/// to attach palette provenance; the RGB colors are complete without it. +public func decodeTRF1StyleDefinitions( + _ section: TRF1Section, + palette paletteSection: TRF1Section? = nil +) throws -> [TRF1StyleDefinition] { try requireKind(section, .styleDefinitions, "wrong style definition section kind") let reader = TRF1Reader(data: section.bytes) try reader.require(offset: 0, length: 4, "truncated style definition header") @@ -469,7 +514,55 @@ public func decodeTRF1StyleDefinitions(_ section: TRF1Section) throws -> [TRF1St ) ) } - return styles + guard let paletteSection else { return styles } + var palette: [UInt32: TRF1StylePaletteEntry] = [:] + for entry in try decodeTRF1StylePalette(paletteSection) { + palette[entry.styleID] = entry + } + return styles.map { style in + guard let entry = palette[style.id] else { return style } + return TRF1StyleDefinition( + id: style.id, + bold: style.bold, + italic: style.italic, + faint: style.faint, + inverse: style.inverse, + invisible: style.invisible, + strikethrough: style.strikethrough, + underline: style.underline, + foreground: style.foreground, + background: style.background, + foregroundPalette: style.foreground == nil ? nil : entry.foreground, + backgroundPalette: style.background == nil ? nil : entry.background + ) + } +} + +public func decodeTRF1StylePalette(_ section: TRF1Section) throws -> [TRF1StylePaletteEntry] { + try requireKind(section, .stylePalette, "wrong style palette section kind") + let reader = TRF1Reader(data: section.bytes) + try reader.require(offset: 0, length: 4, "truncated style palette header") + let count = try reader.uint32(0, "truncated style palette header") + try require(count == section.itemCount, "style palette count mismatch") + try require(Int(count) <= (section.bytes.count - 4) / 8, "invalid style palette length") + try require(section.bytes.count == 4 + Int(count) * 8, "invalid style palette length") + var entries: [TRF1StylePaletteEntry] = [] + entries.reserveCapacity(Int(count)) + for index in 0.. TRF1CursorState { diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaLightAdaptation.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaLightAdaptation.swift new file mode 100644 index 00000000..4551022a --- /dev/null +++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaLightAdaptation.swift @@ -0,0 +1,483 @@ +// Light adaptation: render a dark-painted application (one that fills the +// screen with its own truecolor backgrounds) as a light design under a light +// theme. Colors are remapped per style before blending, so glyph coverage, +// color emoji, and the shader stack are untouched. +// +// This is a port of `packages/ghosttea-react/src/renderers/light-adaptation.ts` +// and must stay numerically in step with it; the tests share expected values. +// +// surfaces mirror around the paper in Lr (OKLab lightness with Ottosson's +// toe, which tracks CIE L*): a panel 0.05 above the app's base +// lands 0.05 below the theme background; the base's tint becomes +// the paper's. Tinted rows step down until a visible tint fits. +// neutral ink keeps its Lr distance from its own cell background, mirrored, +// so primary/secondary/border grays keep their order. +// accents land in a mid-tone band: APCA Lc = clamp(0.5·|Lc| + 28, 45, 70) +// against the new background, chroma raised toward 0.12. +// +// Theme-owned colors (default fg/bg) are left to the theme, and so are +// chromatic ANSI colors (palette 1–6, 9–14): the light theme designed them for +// light paper, so they pass through unless the remapped surface beneath makes +// them less legible than on the theme background. Neutral ANSI colors (0, 7, +// 8, 15) carry a role — "bright text", "dim panel" — rather than a hue, so +// they remap like truecolor. + +import Foundation +import GhostteaFrame + +struct GhostteaLightRGB: Hashable, Sendable { + let red: UInt8 + let green: UInt8 + let blue: UInt8 + + init(_ red: UInt8, _ green: UInt8, _ blue: UInt8) { + self.red = red + self.green = green + self.blue = blue + } + + init(_ color: TRF1RGB) { + self.init(color.red, color.green, color.blue) + } + + init(_ color: GhostteaMetalColor) { + func byte(_ value: Float) -> UInt8 { UInt8((Double(value).clamped() * 255).rounded()) } + self.init(byte(color.red), byte(color.green), byte(color.blue)) + } + + var metalColor: GhostteaMetalColor { + GhostteaMetalColor( + red: Float(red) / 255, green: Float(green) / 255, blue: Float(blue) / 255, alpha: 1) + } + + var hex: String { String((Int(red) << 16) | (Int(green) << 8) | Int(blue), radix: 16) } +} + +private typealias Lab = (L: Double, a: Double, b: Double) + +extension Double { + fileprivate func clamped(_ lo: Double = 0, _ hi: Double = 1) -> Double { + Swift.min(hi, Swift.max(lo, self)) + } +} + +private func toLinear(_ v: Double) -> Double { + v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) +} + +private func toGamma(_ v: Double) -> Double { + v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055 +} + +private func oklab(_ color: GhostteaLightRGB) -> Lab { + let r = toLinear(Double(color.red) / 255) + let g = toLinear(Double(color.green) / 255) + let b = toLinear(Double(color.blue) / 255) + let l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + let m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + let s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + return ( + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + ) +} + +private func oklabToLinear(_ lab: Lab) -> (Double, Double, Double) { + let l1 = lab.L + 0.3963377774 * lab.a + 0.2158037573 * lab.b + let m1 = lab.L - 0.1055613458 * lab.a - 0.0638541728 * lab.b + let s1 = lab.L - 0.0894841775 * lab.a - 1.291485548 * lab.b + let l = l1 * l1 * l1 + let m = m1 * m1 * m1 + let s = s1 * s1 * s1 + return ( + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + ) +} + +private func inGamut(_ linear: (Double, Double, Double)) -> Bool { + [linear.0, linear.1, linear.2].allSatisfy { $0 >= -1e-4 && $0 <= 1 + 1e-4 } +} + +private func fromLch(_ L: Double, _ C: Double, _ h: Double) -> Lab { + (L, C * cos(h), C * sin(h)) +} + +private func toRgb(_ lab: Lab) -> GhostteaLightRGB { + let linear = oklabToLinear(lab) + func byte(_ v: Double) -> UInt8 { UInt8((toGamma(v.clamped()).clamped() * 255).rounded()) } + return GhostteaLightRGB(byte(linear.0), byte(linear.1), byte(linear.2)) +} + +/// Keep L and hue; reduce chroma until the color fits sRGB. +private func gamutMap(_ lightness: Double, _ C: Double, _ h: Double) -> GhostteaLightRGB { + let L = lightness.clamped() + if inGamut(oklabToLinear(fromLch(L, C, h))) { return toRgb(fromLch(L, C, h)) } + var lo = 0.0 + var hi = C + for _ in 0..<20 { + let mid = (lo + hi) / 2 + if inGamut(oklabToLinear(fromLch(L, mid, h))) { lo = mid } else { hi = mid } + } + return toRgb(fromLch(L, lo, h)) +} + +private func maxChroma(_ L: Double, _ h: Double) -> Double { + var lo = 0.0 + var hi = 0.4 + for _ in 0..<16 { + let mid = (lo + hi) / 2 + if inGamut(oklabToLinear(fromLch(L, mid, h))) { lo = mid } else { hi = mid } + } + return lo +} + +private let k1 = 0.206 +private let k2 = 0.03 +private let k3 = (1 + k1) / (1 + k2) + +private func toe(_ L: Double) -> Double { + 0.5 * (k3 * L - k1 + ((k3 * L - k1) * (k3 * L - k1) + 4 * k2 * k3 * L).squareRoot()) +} + +private func toeInverse(_ Lr: Double) -> Double { + (Lr * Lr + k1 * Lr) / (k3 * (Lr + k2)) +} + +// APCA 0.0.98G-4g. Positive Lc = dark text on a light background. +private func apcaY(_ color: GhostteaLightRGB) -> Double { + let y = + 0.2126729 * pow(Double(color.red) / 255, 2.4) + + 0.7151522 * pow(Double(color.green) / 255, 2.4) + + 0.072175 * pow(Double(color.blue) / 255, 2.4) + return y < 0.022 ? y + pow(0.022 - y, 1.414) : y +} + +private func apca(_ text: GhostteaLightRGB, _ background: GhostteaLightRGB) -> Double { + let yt = apcaY(text) + let yb = apcaY(background) + if abs(yb - yt) < 0.0005 { return 0 } + if yb > yt { + let sapc = (pow(yb, 0.56) - pow(yt, 0.57)) * 1.14 + return sapc < 0.1 ? 0 : (sapc - 0.027) * 100 + } + let sapc = (pow(yb, 0.65) - pow(yt, 0.62)) * 1.14 + return sapc > -0.1 ? 0 : (sapc + 0.027) * 100 +} + +private func smoothstep(_ e0: Double, _ e1: Double, _ x: Double) -> Double { + let t = ((x - e0) / (e1 - e0)).clamped() + return t * t * (3 - 2 * t) +} + +private func luminance(_ color: GhostteaLightRGB) -> Double { + 0.2126 * toLinear(Double(color.red) / 255) + + 0.7152 * toLinear(Double(color.green) / 255) + + 0.0722 * toLinear(Double(color.blue) / 255) +} + +private func contrastRatio(_ a: GhostteaLightRGB, _ b: GhostteaLightRGB) -> Double { + let first = luminance(a) + let second = luminance(b) + return (max(first, second) + 0.05) / (min(first, second) + 0.05) +} + +/// Results of `ensureContrast`, shared across renderers like the TypeScript +/// module-level map. `nil` values mean the text already meets the ratio. +private final class GhostteaContrastCache: @unchecked Sendable { + struct Key: Hashable { + let text: GhostteaLightRGB + let background: GhostteaLightRGB + let ratio: Float + } + + static let shared = GhostteaContrastCache() + + private let lock = NSLock() + private var entries: [Key: GhostteaLightRGB?] = [:] + + func value(for key: Key, compute: () -> GhostteaLightRGB?) -> GhostteaLightRGB? { + if let cached = lock.withLock({ entries[key] }) { return cached } + let result = compute() + lock.withLock { + if entries.count > 4096 { entries.removeAll(keepingCapacity: true) } + entries[key] = .some(result) + } + return result + } +} + +enum GhostteaLightColor { + /// Engage when this share of cells carries an explicit dark background… + static let engageShare = 0.6 + /// …and stay engaged until it drops below this, so scrolling content does not flicker. + static let releaseShare = 0.35 + + /// Light when dark text out-contrasts light text: WCAG luminance above ~0.179. Matches the VT shim. + static func isLightBackground(_ color: GhostteaLightRGB) -> Bool { + let y = luminance(color) + return (y + 0.05) / 0.05 > 1.05 / (y + 0.05) + } + + /// Ghostty's `minimum-contrast`: text below the WCAG ratio against its + /// background moves toward whichever of black or white contrasts more. + /// Ghostty snaps straight to that extreme; this keeps hue and chroma and + /// moves OKLab lightness only as far as the ratio needs, reaching black or + /// white only when nothing less suffices. + static func ensureContrast( + _ text: GhostteaMetalColor, + background: GhostteaMetalColor, + ratio: Float + ) -> GhostteaMetalColor { + guard ratio > 1 else { return text } + let fg = GhostteaLightRGB(text) + let bg = GhostteaLightRGB(background) + let target = Double(ratio) + let result = GhostteaContrastCache.shared.value( + for: .init(text: fg, background: bg, ratio: ratio) + ) { () -> GhostteaLightRGB? in + if contrastRatio(fg, bg) >= target { return nil } + let white = GhostteaLightRGB(255, 255, 255) + let black = GhostteaLightRGB(0, 0, 0) + let towardWhite = contrastRatio(white, bg) > contrastRatio(black, bg) + let lab = oklab(fg) + let C = hypot(lab.a, lab.b) + let h = atan2(lab.b, lab.a) + var near = lab.L + var far: Double = towardWhite ? 1 : 0 + var best = towardWhite ? white : black + if contrastRatio(best, bg) >= target { + for _ in 0..<20 { + let mid = (near + far) / 2 + let candidate = gamutMap(mid, C, h) + if contrastRatio(candidate, bg) >= target { + far = mid + best = candidate + } else { + near = mid + } + } + } + return best + } + guard let result else { return text } + return GhostteaMetalColor( + red: Float(result.red) / 255, + green: Float(result.green) / 255, + blue: Float(result.blue) / 255, + alpha: text.alpha + ) + } + + /// The surface a dark-painted application uses as its base, or nil when the + /// pane should render as-is: adaptation is off, the theme is dark, or too few + /// cells carry an explicit dark background (the app follows the theme). + static func darkPaintedBase( + state: RetainedTRF1State, + theme: GhostteaMetalTheme, + engaged: Bool + ) -> GhostteaLightRGB? { + darkPaintedBase( + styleRows: state.rows.lazy.map(\.styles), + rowCount: state.rows.count, + columns: Int(state.columns), + styleDefinitions: state.styleDefinitions, + theme: theme, + engaged: engaged + ) + } + + static func darkPaintedBase( + styleRows: some Sequence<[TRF1StyleRun]>, + rowCount: Int, + columns: Int, + styleDefinitions: [UInt32: TRF1StyleDefinition], + theme: GhostteaMetalTheme, + engaged: Bool + ) -> GhostteaLightRGB? { + guard theme.lightAdaptation, isLightBackground(GhostteaLightRGB(theme.background)) else { + return nil + } + let total = rowCount * columns + guard total > 0 else { return nil } + var areas: [GhostteaLightRGB: Int] = [:] + var order: [GhostteaLightRGB] = [] + var darkness: [GhostteaLightRGB: Bool] = [:] + var dark = 0 + for runs in styleRows { + for run in runs { + let style = styleDefinitions[run.styleID] + guard let raw = style?.inverse == true ? style?.foreground : style?.background else { + continue + } + let background = GhostteaLightRGB(raw) + let isDark: Bool + if let known = darkness[background] { + isDark = known + } else { + isDark = !isLightBackground(background) + darkness[background] = isDark + } + guard isDark else { continue } + dark += Int(run.cellSpan) + if let area = areas[background] { + areas[background] = area + Int(run.cellSpan) + } else { + areas[background] = Int(run.cellSpan) + order.append(background) + } + } + } + guard Double(dark) / Double(total) >= (engaged ? releaseShare : engageShare) else { + return nil + } + var best: GhostteaLightRGB? + var bestArea = -1 + for color in order where areas[color]! > bestArea { + best = color + bestArea = areas[color]! + } + return best + } +} + +/// The remap for one (application base, theme paper, theme ink) triple. Its +/// caches make repeated lookups of the same color cheap, so a renderer keeps +/// one instance per key rather than rebuilding it per frame. +final class GhostteaLightRemap { + private static let chromaticANSI: Set = [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] + + private struct InkKey: Hashable { + let color: GhostteaLightRGB + let sourceBackground: GhostteaLightRGB + let themeColor: Bool + } + + /// Stable identity for cache keys: base, paper, and ink. + let key: String + let base: GhostteaLightRGB + private let paper: GhostteaLightRGB + private let baseLab: Lab + private let paperLab: Lab + private let inkLab: Lab + private let baseLr: Double + private let paperLr: Double + private var surfaces: [GhostteaLightRGB: GhostteaLightRGB] = [:] + private var inks: [InkKey: GhostteaMetalColor] = [:] + + init( + base: GhostteaLightRGB, paper paperColor: GhostteaMetalColor, ink inkColor: GhostteaMetalColor + ) { + let paper = GhostteaLightRGB(paperColor) + let ink = GhostteaLightRGB(inkColor) + self.base = base + self.paper = paper + baseLab = oklab(base) + paperLab = oklab(paper) + inkLab = oklab(ink) + baseLr = toe(baseLab.L) + paperLr = toe(paperLab.L) + key = [base, paper, ink].map(\.hex).joined(separator: "/") + } + + /// A color painted as an area: cell backgrounds and block-element art. + func surface(_ color: GhostteaLightRGB) -> GhostteaMetalColor { + surfaceRGB(color).metalColor + } + + /// A glyph color, judged against the background it was designed on. + /// `paletteIndex` is the ANSI entry the color came from, when known. + func ink( + _ color: GhostteaLightRGB, + sourceBackground: GhostteaLightRGB, + paletteIndex: UInt8? = nil + ) -> GhostteaMetalColor { + let themeColor = paletteIndex.map(Self.chromaticANSI.contains) ?? false + let cacheKey = InkKey(color: color, sourceBackground: sourceBackground, themeColor: themeColor) + if let cached = inks[cacheKey] { return cached } + let mapped = surfaceRGB(sourceBackground) + if themeColor { + let legibility = min(45, abs(apca(color, paper))) + if abs(apca(color, mapped)) >= legibility { + let rgba = color.metalColor + inks[cacheKey] = rgba + return rgba + } + } + let lab = oklab(color) + let C0 = hypot(lab.a, lab.b) + // Neutral ink: mirror its Lr distance from the background, and lean + // toward the theme ink's tint so app grays sit in the chosen theme. + var a = lab.a + var b = lab.b + if C0 < 0.03 { + let w = (1 - C0 / 0.03) * 0.7 + a = lab.a * (1 - w) + inkLab.a * w + b = lab.b * (1 - w) + inkLab.b * w + } + let C = hypot(a, b) + let h = atan2(b, a) + let delta = toe(lab.L) - toe(oklab(sourceBackground).L) + let sign: Double = delta >= 0 ? -1 : 1 + let Lr = (toe(oklab(mapped).L) + sign * max(abs(delta), 0.06)).clamped() + var result = gamutMap(toeInverse(Lr), C, h) + let w = smoothstep(0.03, 0.08, C0) + if w > 0 { + let source = apca(color, sourceBackground) + let lightOnDark = source < 0 || (source == 0 && delta >= 0) + let magnitude = (abs(source) * 0.5 + 28).clamped(45, 70) + let boosted = C0 + w * (0.12 - C0).clamped(0, C0 * 0.8) + let accent = solveLc( + lightOnDark ? magnitude : -magnitude, boosted, atan2(lab.b, lab.a), mapped) + if w >= 1 { + result = accent + } else { + let p = oklab(result) + let q = oklab(accent) + result = toRgb( + (p.L * (1 - w) + q.L * w, p.a * (1 - w) + q.a * w, p.b * (1 - w) + q.b * w)) + } + } + let rgba = result.metalColor + inks[cacheKey] = rgba + return rgba + } + + private func surfaceRGB(_ color: GhostteaLightRGB) -> GhostteaLightRGB { + if let cached = surfaces[color] { return cached } + let lab = oklab(color) + var Lr = (paperLr - (toe(lab.L) - baseLr)).clamped() + let ap = lab.a - baseLab.a + paperLab.a + let bp = lab.b - baseLab.b + paperLab.b + let C = hypot(ap, bp) + let h = atan2(bp, ap) + if C > 0.015 { + let want = min(C, 0.045) + let stop = Lr - 0.15 + while Lr > stop && maxChroma(toeInverse(Lr), h) < want { Lr -= 0.005 } + } + let result = gamutMap(toeInverse(Lr), C, h) + surfaces[color] = result + return result + } + + private func solveLc( + _ want: Double, _ C: Double, _ h: Double, _ background: GhostteaLightRGB + ) -> GhostteaLightRGB { + let backgroundL = oklab(background).L + var lo = want > 0 ? 0 : backgroundL + var hi = want > 0 ? backgroundL : 1 + var best = gamutMap(want > 0 ? 0 : 1, C, h) + for _ in 0..<18 { + let mid = (lo + hi) / 2 + let candidate = gamutMap(mid, C, h) + let lc = apca(candidate, background) + let enough = want > 0 ? lc >= want : lc <= want + if enough { best = candidate } + if (want > 0) == enough { lo = mid } else { hi = mid } + } + return best + } +} diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift index 26c4e9eb..42474e63 100644 --- a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift +++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift @@ -12,6 +12,10 @@ struct GhostteaMetalColor: Equatable, Sendable { static let clear = Self(red: 0, green: 0, blue: 0, alpha: 0) var components: [Float] { [red, green, blue, alpha] } + + func withAlpha(_ alpha: Float) -> Self { + Self(red: red, green: green, blue: blue, alpha: alpha) + } } struct GhostteaMetalTheme: Equatable, Sendable { @@ -25,6 +29,11 @@ struct GhostteaMetalTheme: Equatable, Sendable { var backgroundOpacityCells = false var shaderEffects: [GhostteaMetalShaderEffect] = [] var shaderAnimation = false + /// Restyle panes a dark-painted application fills under a light theme + /// (`ghosttea-light-adaptation = auto`). + var lightAdaptation = false + /// WCAG ratio text keeps against its cell background (`minimum-contrast`, 1 = off). + var minimumContrast: Float = 1 } enum GhostteaMetalShaderEffect: UInt32, CaseIterable, Equatable, Sendable { @@ -126,6 +135,10 @@ struct GhostteaMetalDrawResult: Equatable, Sendable { private struct GhostteaResolvedMetalStyle { let foreground: GhostteaMetalColor let background: GhostteaMetalColor? + /// Foreground used as an area fill (block elements); differs only under light adaptation. + let fill: GhostteaMetalColor + /// Foreground for text glyphs and their decorations, after `minimum-contrast`. + let text: GhostteaMetalColor let underline: Bool let strikethrough: Bool let invisible: Bool @@ -174,6 +187,7 @@ private struct GhostteaMetalRowCacheContext: Equatable { let theme: GhostteaMetalTheme let contentInsets: GhostteaTerminalContentInsets let selection: GhostteaMetalSelection? + let lightAdaptationKey: String? let alphaAtlasResetCount: Int let colorAtlasResetCount: Int } @@ -224,6 +238,7 @@ private struct GhostteaMetalGeometryKey: Equatable { let contentInsets: GhostteaTerminalContentInsets let selection: GhostteaMetalSelection? let focused: Bool + let lightAdaptationKey: String? let alphaAtlasResetCount: Int let colorAtlasResetCount: Int } @@ -421,6 +436,9 @@ final class GhostteaMetalRenderer { private var rowCache: [Int: GhostteaMetalRowCacheEntry] = [:] private var pendingRowRevisions: [Int: UInt64] = [:] private var rowCacheBytes = 0 + private var lightRemap: GhostteaLightRemap? + private var lightRemaps: [String: GhostteaLightRemap] = [:] + private var lightRemapOrder: [String] = [] private var effectSceneTexture: (any MTLTexture)? private var effectIntermediateTextures: [any MTLTexture] = [] private var effectTextureSize = SIMD2(repeating: 0) @@ -609,6 +627,7 @@ final class GhostteaMetalRenderer { Float((focused ? $0.style : TRF1CursorStyle.hollowBlock).rawValue) ) } ?? SIMD4(repeating: 0) + prepareLightAdaptation(state: state, theme: theme) let recorder = GhostteaPerformanceRecorder.shared let lookupKey = encodedGeometryReuseEnabled @@ -762,11 +781,37 @@ final class GhostteaMetalRenderer { contentInsets: contentInsets, selection: selection, focused: focused, + lightAdaptationKey: lightRemap?.key, alphaAtlasResetCount: atlases.alpha.resetCount, colorAtlasResetCount: atlases.colorResetCount ) } + /// Engages, re-keys, or releases light adaptation for this pane. The remap + /// key is part of the geometry key and row-cache context, so any change + /// rebuilds every row with the new colors. + private func prepareLightAdaptation(state: RetainedTRF1State, theme: GhostteaMetalTheme) { + guard + let base = GhostteaLightColor.darkPaintedBase( + state: state, theme: theme, engaged: lightRemap != nil) + else { + lightRemap = nil + return + } + let key = "\(base.hex)|\(theme.background.components)|\(theme.foreground.components)" + if let cached = lightRemaps[key] { + lightRemap = cached + return + } + let remap = GhostteaLightRemap(base: base, paper: theme.background, ink: theme.foreground) + lightRemaps[key] = remap + lightRemapOrder.append(key) + if lightRemapOrder.count > 8 { + lightRemaps[lightRemapOrder.removeFirst()] = nil + } + lightRemap = remap + } + private func visibleGlyphDefinitions(_ state: RetainedTRF1State) throws -> [TRF1GlyphDefinition] { var ids: Set = [] for row in state.rows { @@ -820,6 +865,7 @@ final class GhostteaMetalRenderer { theme: theme, contentInsets: contentInsets, selection: orderedSelection, + lightAdaptationKey: lightRemap?.key, alphaAtlasResetCount: atlases.alpha.resetCount, colorAtlasResetCount: atlases.colorResetCount ) @@ -837,6 +883,8 @@ final class GhostteaMetalRenderer { activity.evictions += clearRowCache() pendingRowRevisions.removeAll(keepingCapacity: true) } + let styles = GhostteaMetalStyleResolver( + definitions: state.styleDefinitions, theme: theme, adaptation: lightRemap) for (rowIndex, row) in state.rows.enumerated() { let rowDamaged = damage.rows.contains(UInt16(clamping: rowIndex)) @@ -865,7 +913,8 @@ final class GhostteaMetalRenderer { originX: originX, originY: originY, selection: orderedSelection, - blockCursorColumn: blockCursorColumn + blockCursorColumn: blockCursorColumn, + styles: styles ) append(rowMesh, to: &mesh) if !broadDamage, pendingRowRevisions[rowIndex] == row.revision, @@ -982,11 +1031,18 @@ final class GhostteaMetalRenderer { originX: Float, originY: Float, selection: GhostteaMetalSelection?, - blockCursorColumn: UInt16? + blockCursorColumn: UInt16?, + styles: GhostteaMetalStyleResolver ) throws -> GhostteaMetalRowMesh { var mesh = GhostteaMetalRowMesh() + // Graphics (box drawing, block elements) keep the raw foreground rather + // than the contrast-adjusted text color, as in Ghostty; block elements + // are areas, so light adaptation maps them as surfaces. Only computed + // when those colors can differ. + let graphics = + styles.distinguishesGraphics ? GhostteaMetalGraphicCells.cells(in: row.text) : [:] for run in row.styles { - let style = resolveStyle(state.styleDefinitions[run.styleID], theme: theme) + let style = styles[run.styleID] if let background = style.background { pushRectangle( into: &mesh.backgrounds, @@ -1002,7 +1058,7 @@ final class GhostteaMetalRenderer { } for instance in row.glyphs { guard let definition = state.glyphDefinitions[instance.glyphID] else { continue } - let style = resolveStyle(state.styleDefinitions[instance.styleID], theme: theme) + let style = styles[instance.styleID] if style.invisible { continue } let glyphStart = Int(instance.cellStart) let glyphEnd = glyphStart + max(1, Int(instance.cellSpan)) @@ -1010,14 +1066,22 @@ final class GhostteaMetalRenderer { blockCursorColumn.map { glyphStart <= Int($0) && Int($0) < glyphEnd } ?? false - let foreground = - selectionContains(selection, row: rowIndex, column: glyphStart) - ? theme.selectionForeground - : style.foreground + let selected = selectionContains(selection, row: rowIndex, column: glyphStart) + let foreground = selected ? theme.selectionForeground : style.foreground guard let location = atlases.location(for: definition) else { throw TRF1DecodingError("visible glyph \(definition.id) is absent from its atlas") } if definition.format == .alpha8 { + let glyphColor: GhostteaMetalColor + if selected { + glyphColor = foreground + } else { + switch graphics[glyphStart] { + case .box: glyphColor = style.foreground + case .block: glyphColor = style.fill + case nil: glyphColor = style.text + } + } pushGlyph( into: &mesh.alphaGlyphs, x: (originX + instance.x) * scale, @@ -1025,7 +1089,7 @@ final class GhostteaMetalRenderer { width: instance.width * scale, height: instance.height * scale, location: location, - color: foreground, + color: glyphColor, viewportWidth: width, viewportHeight: height ) @@ -1072,7 +1136,7 @@ final class GhostteaMetalRenderer { } } for run in row.styles { - let style = resolveStyle(state.styleDefinitions[run.styleID], theme: theme) + let style = styles[run.styleID] if style.invisible { continue } let x = (originX + Float(run.cellStart) * cellWidth) * scale let rowTop = (originY + Float(rowIndex) * lineHeight) * scale @@ -1091,7 +1155,7 @@ final class GhostteaMetalRenderer { y: (rowTop + 16 * metricScale * scale).rounded(), width: runWidth, height: stroke, - color: style.foreground, + color: style.text, viewportWidth: width, viewportHeight: height ) @@ -1115,7 +1179,7 @@ final class GhostteaMetalRenderer { y: (rowTop + 9 * metricScale * scale).rounded(), width: runWidth, height: stroke, - color: style.foreground, + color: style.text, viewportWidth: width, viewportHeight: height ) @@ -1884,16 +1948,116 @@ final class GhostteaMetalRenderer { } } +/// Resolves each style once per mesh build. Light adaptation and +/// `minimum-contrast` search colors, so per-glyph resolution would repeat that +/// work for every cell sharing a style. +private final class GhostteaMetalStyleResolver { + private let definitions: [UInt32: TRF1StyleDefinition] + private let theme: GhostteaMetalTheme + private let adaptation: GhostteaLightRemap? + private var resolved: [UInt32: GhostteaResolvedMetalStyle] = [:] + + init( + definitions: [UInt32: TRF1StyleDefinition], + theme: GhostteaMetalTheme, + adaptation: GhostteaLightRemap? + ) { + self.definitions = definitions + self.theme = theme + self.adaptation = adaptation + } + + /// Whether graphics cells can need a color other than the text color. + var distinguishesGraphics: Bool { adaptation != nil || theme.minimumContrast > 1 } + + subscript(id: UInt32) -> GhostteaResolvedMetalStyle { + if let style = resolved[id] { return style } + let style = resolveStyle(definitions[id], theme: theme, adaptation: adaptation) + resolved[id] = style + return style + } +} + +private enum GhostteaMetalGraphicCell { + case box + case block +} + +enum GhostteaMetalGraphicCells { + /// Columns holding box-drawing (U+2500–257F) or block-element (U+2580–259F) + /// characters, found by walking the row text with the desktop's cell widths. + fileprivate static func cells(in text: String) -> [Int: GhostteaMetalGraphicCell] { + if text.utf8.allSatisfy({ $0 < 0x80 }) { return [:] } + var cells: [Int: GhostteaMetalGraphicCell] = [:] + var column = 0 + for character in text { + switch character.unicodeScalars.first?.value ?? 0 { + case 0x2500...0x257F: cells[column] = .box + case 0x2580...0x259F: cells[column] = .block + default: break + } + column += cellWidth(character) + } + return cells + } + + /// Mirrors `graphemeCellWidth` in `@vibecook/ghosttea-react`. Swift has no + /// Extended_Pictographic property, so non-ASCII emoji other than regional + /// indicators stand in for it. + static func cellWidth(_ character: Character) -> Int { + let pictographic = character.unicodeScalars.contains { + $0.value > 0x7F && $0.properties.isEmoji && !(0x1F1E6...0x1F1FF).contains($0.value) + } + if pictographic { return 2 } + switch character.unicodeScalars.first?.value ?? 0 { + case 0x1100...0x115F, 0x2E80...0xA4CF, 0xAC00...0xD7A3, 0xF900...0xFAFF, 0xFE10...0xFE6F, + 0xFF00...0xFF60, 0x1F300...0x1FAFF, 0x20000...0x3FFFD: + return 2 + default: + return 1 + } + } +} + private func resolveStyle( _ style: TRF1StyleDefinition?, - theme: GhostteaMetalTheme + theme: GhostteaMetalTheme, + adaptation: GhostteaLightRemap? = nil ) -> GhostteaResolvedMetalStyle { var foreground = style?.foreground.map(metalColor) ?? theme.foreground var background = style?.background.map(metalColor) - if style?.inverse == true { + var fill = foreground + if let adaptation { + // Application colors were designed on the app's dark base; theme-owned + // defaults already belong to the light theme and pass through. + let sourceForeground = style?.foreground.map { GhostteaLightRGB($0) } + let sourceBackground = style?.background.map { GhostteaLightRGB($0) } + if style?.inverse == true { + background = sourceForeground.map { adaptation.surface($0) } ?? theme.foreground + foreground = + sourceBackground.map { + adaptation.ink( + $0, + sourceBackground: sourceForeground ?? adaptation.base, + paletteIndex: style?.backgroundPalette) + } ?? theme.background + fill = foreground + } else { + foreground = + sourceForeground.map { + adaptation.ink( + $0, + sourceBackground: sourceBackground ?? adaptation.base, + paletteIndex: style?.foregroundPalette) + } ?? theme.foreground + background = sourceBackground.map { adaptation.surface($0) } + fill = sourceForeground.map { adaptation.surface($0) } ?? theme.foreground + } + } else if style?.inverse == true { let originalForeground = foreground foreground = background ?? theme.background background = originalForeground + fill = foreground } if let cellBackground = background, theme.backgroundOpacityCells { background = GhostteaMetalColor( @@ -1903,23 +2067,49 @@ private func resolveStyle( alpha: theme.background.alpha ) } + // Graphics and color glyphs keep the raw foreground, as in Ghostty; the + // backdrop ignores window transparency. + var text = GhostteaLightColor.ensureContrast( + foreground, + background: over(background ?? theme.background, theme.background), + ratio: theme.minimumContrast + ) if style?.faint == true { - foreground = GhostteaMetalColor( - red: foreground.red, - green: foreground.green, - blue: foreground.blue, - alpha: foreground.alpha * 0.55 - ) + foreground = foreground.withAlpha(foreground.alpha * 0.55) + fill = fill.withAlpha(fill.alpha * 0.55) + text = text.withAlpha(text.alpha * 0.55) } return GhostteaResolvedMetalStyle( foreground: foreground, background: background, + fill: fill, + text: text, underline: style?.underline ?? false, strikethrough: style?.strikethrough ?? false, invisible: style?.invisible ?? false ) } +/// Source-over composite of straight-alpha colors. +private func over( + _ source: GhostteaMetalColor, + _ backdrop: GhostteaMetalColor +) -> GhostteaMetalColor { + if source.alpha >= 1 { return source } + if source.alpha <= 0 { return backdrop } + let alpha = source.alpha + backdrop.alpha * (1 - source.alpha) + if alpha <= Float.ulpOfOne { return .clear } + func channel(_ s: Float, _ b: Float) -> Float { + (s * source.alpha + b * backdrop.alpha * (1 - source.alpha)) / alpha + } + return GhostteaMetalColor( + red: channel(source.red, backdrop.red), + green: channel(source.green, backdrop.green), + blue: channel(source.blue, backdrop.blue), + alpha: alpha + ) +} + private func metalColor(_ color: TRF1RGB) -> GhostteaMetalColor { GhostteaMetalColor( red: Float(color.red) / 255, diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift index 7b54e205..e1a9174f 100644 --- a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift +++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift @@ -51,7 +51,9 @@ public enum GhostteaTerminalFrameDecoder { case .glyphDefinitions: glyphDefinitionCount += try decodeTRF1GlyphDefinitions(section).count case .styleDefinitions: - styleDefinitionCount += try decodeTRF1StyleDefinitions(section).count + styleDefinitionCount += try decodeTRF1StyleDefinitions( + section, palette: frame.sections.first { $0.kind == .stylePalette } + ).count case .rowReplacements: rowReplacementCount += try decodeTRF1RowReplacements(section).count case .cursorState: diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift index 000cf11c..141a3ec6 100644 --- a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift +++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift @@ -1488,7 +1488,10 @@ selectionForeground: selectionForeground, backgroundOpacityCells: config.backgroundOpacityCells, shaderEffects: effects, - shaderAnimation: config.customShaderAnimation + shaderAnimation: config.customShaderAnimation, + lightAdaptation: config.lightAdaptation == .auto, + minimumContrast: config.minimumContrast.isFinite + ? min(21, max(1, config.minimumContrast)) : 1 ) clearColor = MTLClearColor( red: Double(background.red * background.alpha), diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift index acdc07d9..f33785d6 100644 --- a/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift +++ b/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift @@ -171,7 +171,10 @@ struct RetainedTRF1State: Equatable, Sendable { decodeTRF1AccessibilityRows) ?? [] let nextCursor = try decodeTRF1CursorState(cursorSection) let glyphs = try sections[.glyphDefinitions].map(decodeTRF1GlyphDefinitions) ?? [] - let styles = try sections[.styleDefinitions].map(decodeTRF1StyleDefinitions) ?? [] + let styles = + try sections[.styleDefinitions].map { + try decodeTRF1StyleDefinitions($0, palette: sections[.stylePalette]) + } ?? [] let nextScrollbar = try sections[.scrollbarState].map(decodeTRF1ScrollbarState) let nextSelection = try sections[.selectionSpans].map(decodeTRF1SelectionState) let clipboardWrites = try clipboardSections.map(decodeTRF1ClipboardWrite) diff --git a/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift b/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift index 50e5164d..e2be2d79 100644 --- a/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift +++ b/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift @@ -51,6 +51,36 @@ import Testing #expect(!patched.replacingOccurrences(of: "\r\n", with: "").contains("\n")) } +@Test func appearanceBlockWritesReadabilitySettingsLikeTheDesktop() throws { + let defaults = try GhostteaConfigurationDraft.appearanceBlock(GhostteaAppearanceSelection()) + #expect(defaults.contains("\nghosttea-light-adaptation = auto\n")) + #expect(defaults.contains("\nminimum-contrast = 1\n")) + #expect( + defaults.hasSuffix( + "minimum-contrast = 1\n" + GhostteaConfigurationDraft.appearanceBlockEnd)) + + for (ratio, written) in [(3.0, "3"), (4.5, "4.5"), (7.0, "7"), (2.25, "2.25")] { + let block = try GhostteaConfigurationDraft.appearanceBlock( + GhostteaAppearanceSelection(lightAdaptation: .off, minimumContrast: ratio)) + #expect(block.contains("\nghosttea-light-adaptation = off\n")) + #expect(block.contains("\nminimum-contrast = \(written)\n")) + } + #expect(GhostteaAppearanceSelection.minimumContrastChoices == [1, 3, 4.5, 7]) + + for invalid in [0.5, 22, .nan, .infinity] { + #expect(throws: GhostteaConfigDraftError.invalidTheme) { + try GhostteaConfigurationDraft.appearanceBlock( + GhostteaAppearanceSelection(minimumContrast: invalid)) + } + } +} + +@Test func appearanceSelectionDefaultsReadabilityForConfigsWithoutIt() throws { + let selection = GhostteaAppearanceSelection(config: try testConfiguration()) + #expect(selection.lightAdaptation == .auto) + #expect(selection.minimumContrast == 1) +} + @Test func appearancePatchRejectsAmbiguousManagedMarkers() { #expect(throws: GhostteaConfigDraftError.self) { try GhostteaConfigurationDraft.patchAppearance( diff --git a/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift b/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift index c7e14009..dda1319e 100644 --- a/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift +++ b/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift @@ -89,6 +89,8 @@ import Testing palette: [GhostteaPaletteConfigEntry(index: 2, color: [19, 20, 21])], backgroundOpacity: 0.5, backgroundOpacityCells: true, + minimumContrast: 4.5, + lightAdaptation: .off, fontSize: 13, fontFamilies: [], paddingX: [2, 2], @@ -101,7 +103,7 @@ import Testing JSONSerialization.jsonObject(with: JSONEncoder().encode(current)) as? [String: Any]) for key in [ "cursorText", "palette", "backgroundOpacity", "backgroundOpacityCells", "shaderEffects", - "customShaderAnimation", + "customShaderAnimation", "minimumContrast", "lightAdaptation", ] { object[key] = nil } @@ -115,9 +117,48 @@ import Testing #expect(!decoded.backgroundOpacityCells) #expect(decoded.shaderEffects.isEmpty) #expect(!decoded.customShaderAnimation) + #expect(decoded.minimumContrast == 1) + #expect(decoded.lightAdaptation == .auto) #expect(decoded.isValid) } +@Test func presentationRoundTripsReadabilitySettingsWithTheDesktopSpelling() throws { + let presentation = GhostteaTerminalPresentationConfig( + schemaVersion: 1, + revision: "readability", + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + selectionBackground: [13, 14, 15], + selectionForeground: [16, 17, 18], + minimumContrast: 4.5, + lightAdaptation: .off, + fontSize: 13, + fontFamilies: [], + paddingX: [2, 2], + paddingY: [2, 2], + postProcess: .none, + customShaderCount: 0) + let object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(presentation)) as? [String: Any]) + #expect(object["minimumContrast"] as? Double == 4.5) + #expect(object["lightAdaptation"] as? String == "off") + let decoded = try JSONDecoder().decode( + GhostteaTerminalPresentationConfig.self, + from: JSONSerialization.data(withJSONObject: object)) + #expect(decoded == presentation) + #expect( + throws: DecodingError.self, + "Unknown modes are rejected like the Rust enum" + ) { + var invalid = object + invalid["lightAdaptation"] = "sometimes" + _ = try JSONDecoder().decode( + GhostteaTerminalPresentationConfig.self, + from: JSONSerialization.data(withJSONObject: invalid)) + } +} + @Test func configurationStoreValidatesSavesAndRejectsStaleRevisions() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("ghosttea-config-store-\(UUID().uuidString)", isDirectory: true) diff --git a/apple/GhostteaKit/Tests/GhostteaFrameTests/GhostteaLightAdaptationTests.swift b/apple/GhostteaKit/Tests/GhostteaFrameTests/GhostteaLightAdaptationTests.swift new file mode 100644 index 00000000..028ddfa8 --- /dev/null +++ b/apple/GhostteaKit/Tests/GhostteaFrameTests/GhostteaLightAdaptationTests.swift @@ -0,0 +1,314 @@ +import Foundation +import GhostteaCore +import Metal +import Testing + +@testable import GhostteaFrame +@testable import GhostteaTerminal + +// Ports `packages/ghosttea-react/src/renderers/light-adaptation.test.ts`: the +// same inputs and tolerances hold the Metal and WebGPU remaps in step. + +private func rgb(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> GhostteaLightRGB { + GhostteaLightRGB(red, green, blue) +} + +private func rgba(_ color: GhostteaLightRGB, alpha: Float = 1) -> GhostteaMetalColor { + color.metalColor.withAlpha(alpha) +} + +private func bytes(_ color: GhostteaMetalColor) -> [Int] { + [color.red, color.green, color.blue].map { Int(($0 * 255).rounded()) } +} + +private func expectNear( + _ actual: GhostteaMetalColor, + _ expected: GhostteaLightRGB, + tolerance: Int = 6, + sourceLocation: SourceLocation = #_sourceLocation +) { + let expectedBytes = [Int(expected.red), Int(expected.green), Int(expected.blue)] + for (value, target) in zip(bytes(actual), expectedBytes) { + #expect( + abs(value - target) <= tolerance, "\(bytes(actual)) vs \(expectedBytes)", + sourceLocation: sourceLocation) + } +} + +// GrokNight's base and GrokDay's paper, with a neutral ink so no tint shifts. +private var grok: GhostteaLightRemap { + GhostteaLightRemap( + base: rgb(0x14, 0x14, 0x14), + paper: rgba(rgb(0xee, 0xee, 0xee)), + ink: rgba(rgb(0x26, 0x26, 0x26))) +} + +@Test func lightAdaptationMapsTheBaseSurfaceOntoThePaper() { + expectNear(grok.surface(rgb(0x14, 0x14, 0x14)), rgb(0xee, 0xee, 0xee), tolerance: 1) +} + +@Test func lightAdaptationKeepsTheGrayHierarchy() { + expectNear( + grok.ink(rgb(0xe1, 0xe1, 0xe1), sourceBackground: grok.base), rgb(0x26, 0x26, 0x26), + tolerance: 10) + expectNear( + grok.ink(rgb(0x73, 0x73, 0x73), sourceBackground: grok.base), rgb(0x74, 0x74, 0x74), + tolerance: 16) + expectNear( + grok.ink(rgb(0x33, 0x33, 0x33), sourceBackground: grok.base), rgb(0xcd, 0xcd, 0xcd), + tolerance: 8) +} + +@Test func lightAdaptationKeepsAnAccentHue() { + let accent = grok.ink(rgb(0xe0, 0xaf, 0x68), sourceBackground: grok.base) + let channels = bytes(accent) + #expect(channels[0] > channels[1]) + #expect(channels[1] > channels[2]) + expectNear(accent, rgb(0xa2, 0x76, 0x12), tolerance: 14) +} + +@Test func lightAdaptationKeepsAVisibleTintOnDarkDiffRows() { + let claude = GhostteaLightRemap( + base: rgb(0x1e, 0x1e, 0x2e), + paper: rgba(rgb(0xef, 0xf1, 0xf5)), + ink: rgba(rgb(0x4c, 0x4f, 0x69))) + let channels = bytes(claude.surface(rgb(0x3d, 0x01, 0x00))) + #expect(channels[0] > 240) + #expect(channels[0] - min(channels[1], channels[2]) > 20) +} + +@Test func lightAdaptationKeepsLegibleThemeANSIColors() { + let red = rgb(0xd2, 0x0f, 0x39) // Catppuccin Latte red, designed for light paper + let latte = GhostteaLightRemap( + base: rgb(0x1e, 0x1e, 0x2e), + paper: rgba(rgb(0xef, 0xf1, 0xf5)), + ink: rgba(rgb(0x4c, 0x4f, 0x69))) + #expect( + bytes(latte.ink(red, sourceBackground: latte.base, paletteIndex: 1)) == [0xd2, 0x0f, 0x39]) + // The same RGB as truecolor is an application color and is remapped. + #expect(bytes(latte.ink(red, sourceBackground: latte.base)) != [0xd2, 0x0f, 0x39]) + // Neutral entries describe a role, not a hue: "white" text still darkens. + let white = bytes(latte.ink(rgb(0xbc, 0xc0, 0xcc), sourceBackground: latte.base, paletteIndex: 7)) + #expect(white[0] < 0x80) +} + +@Test func lightAdaptationClassifiesBackgroundsLikeTheVTShim() { + #expect(GhostteaLightColor.isLightBackground(rgb(0xef, 0xf1, 0xf5))) + #expect(!GhostteaLightColor.isLightBackground(rgb(0x1e, 0x1e, 0x2e))) +} + +private func contrast(_ a: GhostteaMetalColor, _ b: GhostteaMetalColor) -> Double { + func luminance(_ color: GhostteaMetalColor) -> Double { + zip([0.2126, 0.7152, 0.0722], [color.red, color.green, color.blue]).reduce(0) { sum, pair in + let v = Double(pair.1) + return sum + pair.0 * (v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)) + } + } + let first = luminance(a) + let second = luminance(b) + return (max(first, second) + 0.05) / (min(first, second) + 0.05) +} + +private let panel = rgba(rgb(0xe6, 0xe6, 0xe6)) + +@Test func minimumContrastLeavesTextAloneWhenOffOrAlreadyMet() { + let gray = rgba(rgb(0x9a, 0x9a, 0x9a)) + #expect(GhostteaLightColor.ensureContrast(gray, background: panel, ratio: 1) == gray) + let ink = rgba(rgb(0x26, 0x26, 0x26)) + #expect(GhostteaLightColor.ensureContrast(ink, background: panel, ratio: 4.5) == ink) +} + +@Test func minimumContrastMovesLightnessOnlyAsFarAsNeededAndKeepsHue() { + let amber = rgba(rgb(0xe0, 0xaf, 0x68), alpha: 0.55) + let result = GhostteaLightColor.ensureContrast(amber, background: panel, ratio: 4.5) + #expect(contrast(result, panel) >= 4.5) + #expect(contrast(result, panel) < 4.7) + let channels = bytes(result) + #expect(channels[0] > channels[1]) + #expect(channels[1] > channels[2]) + #expect(result.alpha == 0.55) +} + +@Test func minimumContrastReachesBlackOrWhiteWhenNothingLessSuffices() { + let gray = rgba(rgb(0x80, 0x80, 0x80)) + #expect(bytes(GhostteaLightColor.ensureContrast(gray, background: gray, ratio: 21)) == [0, 0, 0]) + let lifted = GhostteaLightColor.ensureContrast( + rgba(rgb(0x30, 0x30, 0x30)), background: rgba(rgb(0x20, 0x20, 0x20)), ratio: 7) + #expect(bytes(lifted)[0] > 0x80) +} + +private let paintedStyle = TRF1StyleDefinition( + id: 7, bold: false, italic: false, faint: false, inverse: false, invisible: false, + strikethrough: false, underline: false, foreground: nil, + background: TRF1RGB(red: 0x14, green: 0x14, blue: 0x14)) + +private func lightTheme(adaptation: Bool = true) -> GhostteaMetalTheme { + var theme = GhostteaMetalTheme() + theme.background = rgba(rgb(0xef, 0xf1, 0xf5)) + theme.foreground = rgba(rgb(0x4c, 0x4f, 0x69)) + theme.lightAdaptation = adaptation + return theme +} + +private func paintedBase( + _ theme: GhostteaMetalTheme, paintedRows: Int, engaged: Bool +) -> GhostteaLightRGB? { + let painted = [TRF1StyleRun(styleID: 7, cellStart: 0, cellSpan: 10)] + return GhostteaLightColor.darkPaintedBase( + styleRows: (0..<10).map { $0 < paintedRows ? painted : [] }, + rowCount: 10, + columns: 10, + styleDefinitions: [7: paintedStyle], + theme: theme, + engaged: engaged) +} + +@Test func lightAdaptationEngagesForADarkPaintedPaneUnderALightTheme() { + #expect(paintedBase(lightTheme(), paintedRows: 10, engaged: false) == rgb(0x14, 0x14, 0x14)) +} + +@Test func lightAdaptationStaysOffForDarkThemesOptOutsAndThemeFollowingApps() { + var dark = lightTheme() + dark.background = rgba(rgb(0x1e, 0x1e, 0x2e)) + #expect(paintedBase(dark, paintedRows: 10, engaged: false) == nil) + #expect(paintedBase(lightTheme(adaptation: false), paintedRows: 10, engaged: false) == nil) + #expect(paintedBase(lightTheme(), paintedRows: 1, engaged: false) == nil) +} + +@Test func lightAdaptationUsesHysteresisSoPartialScrollsDoNotFlicker() { + #expect(paintedBase(lightTheme(), paintedRows: 5, engaged: false) == nil) + #expect(paintedBase(lightTheme(), paintedRows: 5, engaged: true) == rgb(0x14, 0x14, 0x14)) + #expect(paintedBase(lightTheme(), paintedRows: 3, engaged: true) == nil) +} + +@Test func graphicCellsFollowDesktopCellWidths() { + #expect(GhostteaMetalGraphicCells.cellWidth("界") == 2) + #expect(GhostteaMetalGraphicCells.cellWidth("🙂") == 2) + #expect(GhostteaMetalGraphicCells.cellWidth("─") == 1) + #expect(GhostteaMetalGraphicCells.cellWidth("a") == 1) +} + +private func pixel(_ texture: any MTLTexture, x: Int, y: Int) -> [Int] { + var value = [UInt8](repeating: 0, count: 4) + value.withUnsafeMutableBytes { bytes in + texture.getBytes( + bytes.baseAddress!, + bytesPerRow: 4, + from: MTLRegionMake2D(x, y, 1, 1), + mipmapLevel: 0) + } + return value.map(Int.init) +} + +@Test func metalRendererRestylesADarkPaintedPaneUnderALightTheme() async throws { + let runtime = try GhostteaRuntime() + let terminal = try GhostteaTerminal( + runtime: runtime, + configuration: .init(sessionHandle: 211, columns: 40, rows: 6)) + // Paint every cell #141414 the way Grok's default theme does, then write + // light-gray text over it. + let paint = "\u{1b}[48;2;20;20;20m\u{1b}[2J\u{1b}[H\u{1b}[38;2;225;225;225mGrok night\r\n" + let update = try await terminal.feed(Data(paint.utf8), render: .full) + var state = RetainedTRF1State() + _ = try state.apply(try #require(update.effects.first { $0.kind == .frameReady }?.payload)) + #expect( + state.styleDefinitions.values.contains { + $0.background == TRF1RGB(red: 20, green: 20, blue: 20) + }) + + let metal = try GhostteaMetalRuntime() + let renderer = try GhostteaMetalRenderer(runtime: metal, alphaAtlasSize: 512, colorAtlasSize: 512) + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba8Unorm, width: 240, height: 80, mipmapped: false) + descriptor.storageMode = .shared + descriptor.usage = [.renderTarget] + let target = try #require(metal.device.makeTexture(descriptor: descriptor)) + // A blank cell on the third row, clear of the text and the cursor. + let probe = ( + x: Int(GhostteaMetalRenderer.originX + 20 * renderer.cellWidth), + y: Int(GhostteaMetalRenderer.originY + 2.5 * renderer.lineHeight) + ) + + var theme = lightTheme(adaptation: false) + _ = try renderer.render(state: state, target: target, theme: theme) + let asPainted = pixel(target, x: probe.x, y: probe.y) + #expect(asPainted[0] < 0x30 && asPainted[1] < 0x30 && asPainted[2] < 0x30, "\(asPainted)") + + // Enabling adaptation on the same renderer must invalidate cached geometry. + theme.lightAdaptation = true + _ = try renderer.render(state: state, target: target, theme: theme) + let adapted = pixel(target, x: probe.x, y: probe.y) + // The app's base surface lands on the theme paper. + #expect( + abs(adapted[0] - 0xef) <= 2 && abs(adapted[1] - 0xf1) <= 2 && abs(adapted[2] - 0xf5) <= 2, + "\(adapted)") + + theme.lightAdaptation = false + _ = try renderer.render(state: state, target: target, theme: theme) + #expect(pixel(target, x: probe.x, y: probe.y) == asPainted) +} + +@Test func metalRendererAppliesMinimumContrastToTextOnly() async throws { + let runtime = try GhostteaRuntime() + let terminal = try GhostteaTerminal( + runtime: runtime, + configuration: .init(sessionHandle: 212, columns: 40, rows: 4)) + // Pale gray text and a block element in the same pale gray on a light theme. + let text = "\u{1b}[38;2;200;200;200mpale text \u{2588}\u{2588}\r\n" + let update = try await terminal.feed(Data(text.utf8), render: .full) + var state = RetainedTRF1State() + _ = try state.apply(try #require(update.effects.first { $0.kind == .frameReady }?.payload)) + + let metal = try GhostteaMetalRuntime() + let renderer = try GhostteaMetalRenderer(runtime: metal, alphaAtlasSize: 512, colorAtlasSize: 512) + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba8Unorm, width: 240, height: 40, mipmapped: false) + descriptor.storageMode = .shared + descriptor.usage = [.renderTarget] + let target = try #require(metal.device.makeTexture(descriptor: descriptor)) + let block = ( + x: Int(GhostteaMetalRenderer.originX + 10.5 * renderer.cellWidth), + y: Int(GhostteaMetalRenderer.originY + 0.5 * renderer.lineHeight) + ) + + var theme = lightTheme(adaptation: false) + let plain = try renderer.render(state: state, width: 240, height: 40, theme: theme) + _ = try renderer.render(state: state, target: target, theme: theme) + let plainBlock = pixel(target, x: block.x, y: block.y) + #expect(abs(plainBlock[0] - 200) <= 2, "\(plainBlock)") + + theme.minimumContrast = 4.5 + let contrasted = try renderer.render(state: state, width: 240, height: 40, theme: theme) + // Darker text lowers the mean; the cached geometry from the first render is not reused. + #expect(contrasted.pixelHash != plain.pixelHash) + #expect(contrasted.visualFingerprint.meanRed < plain.visualFingerprint.meanRed) + // Block elements are graphics and keep the raw foreground, as in Ghostty. + _ = try renderer.render(state: state, target: target, theme: theme) + #expect(pixel(target, x: block.x, y: block.y) == plainBlock) + + theme.minimumContrast = 1 + #expect( + try renderer.render(state: state, width: 240, height: 40, theme: theme).pixelHash + == plain.pixelHash) +} + +@Test func productionFramesCarryPaletteProvenanceBesideTheRGB() async throws { + let terminal = try GhostteaTerminal( + runtime: try GhostteaRuntime(), + configuration: .init(sessionHandle: 212, columns: 20, rows: 2)) + // ANSI red and the same RGB as truecolor must reach the renderer as two + // styles, and only the palette one may pass through light adaptation. + let probe = try await terminal.feed(Data("\u{1b}[31mA".utf8), render: .full) + var probeState = RetainedTRF1State() + _ = try probeState.apply( + try #require(probe.effects.first { $0.kind == .frameReady }?.payload)) + let red = try #require(probeState.styleDefinitions.values.first { $0.foregroundPalette == 1 }) + let rgb = try #require(red.foreground) + let truecolor = "\u{1b}[38;2;\(rgb.red);\(rgb.green);\(rgb.blue)mB" + let update = try await terminal.feed(Data(truecolor.utf8), render: .full) + var state = RetainedTRF1State() + _ = try state.apply(try #require(update.effects.first { $0.kind == .frameReady }?.payload)) + let sameRGB = state.styleDefinitions.values.filter { $0.foreground == rgb } + #expect(sameRGB.count == 2) + #expect(Set(sameRGB.map(\.foregroundPalette)) == [1, nil]) +} diff --git a/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift b/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift index 40473e26..86328edf 100644 --- a/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift +++ b/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift @@ -102,6 +102,8 @@ private func exerciseEveryTRF1Decoder(_ data: Data) { _ = try? decodeTRF1GlyphDefinitions(section) case .styleDefinitions: _ = try? decodeTRF1StyleDefinitions(section) + case .stylePalette: + _ = try? decodeTRF1StylePalette(section) case .rowReplacements: _ = try? decodeTRF1RowReplacements(section) case .cursorState: @@ -554,3 +556,126 @@ private func exerciseEveryTRF1Decoder(_ data: Data) { #expect(Set(state.glyphDefinitions.keys) == Set(replacementGlyphs.map(\.id))) #expect(state.rows[0].text == "x") } + +private func styleSection( + _ styles: [(id: UInt32, foreground: Bool, background: Bool)] +) -> TRF1Section { + var bytes = Data(repeating: 0, count: 4 + styles.count * 16) + writeUInt32(UInt32(styles.count), to: &bytes, at: 0) + for (index, style) in styles.enumerated() { + let offset = 4 + index * 16 + writeUInt32(style.id, to: &bytes, at: offset) + bytes[offset + 6] = style.foreground ? 1 : 0 + bytes[offset + 7] = style.background ? 1 : 0 + bytes[offset + 8] = 0xcc + bytes[offset + 11] = 0x81 + } + return TRF1Section( + kind: .styleDefinitions, flags: 0, itemCount: UInt32(styles.count), bytes: bytes) +} + +private func paletteSection( + _ entries: [(id: UInt32, flags: UInt8, foreground: UInt8, background: UInt8)], + itemCount: UInt32? = nil +) -> TRF1Section { + var bytes = Data(repeating: 0, count: 4 + entries.count * 8) + writeUInt32(UInt32(entries.count), to: &bytes, at: 0) + for (index, entry) in entries.enumerated() { + let offset = 4 + index * 8 + writeUInt32(entry.id, to: &bytes, at: offset) + bytes[offset + 4] = entry.flags + bytes[offset + 5] = entry.foreground + bytes[offset + 6] = entry.background + } + return TRF1Section( + kind: .stylePalette, flags: 0, itemCount: itemCount ?? UInt32(entries.count), bytes: bytes) +} + +@Test func decodesStylePaletteProvenanceOnlyForExplicitColors() throws { + let styles = styleSection([(1, true, false), (2, true, true), (3, false, false)]) + let palette = paletteSection([(1, 0b11, 9, 4), (2, 0b10, 0, 12)]) + #expect( + try decodeTRF1StylePalette(palette) == [ + TRF1StylePaletteEntry(styleID: 1, foreground: 9, background: 4), + TRF1StylePaletteEntry(styleID: 2, foreground: nil, background: 12), + ]) + + let decoded = try decodeTRF1StyleDefinitions(styles, palette: palette) + // Style 1 has no explicit background, so its background index is dropped. + #expect(decoded[0].foregroundPalette == 9) + #expect(decoded[0].backgroundPalette == nil) + #expect(decoded[1].foregroundPalette == nil) + #expect(decoded[1].backgroundPalette == 12) + #expect(decoded[2].foregroundPalette == nil && decoded[2].backgroundPalette == nil) + // RGB stays authoritative: decoding without the section differs only in provenance. + let plain = try decodeTRF1StyleDefinitions(styles) + #expect(plain.map(\.foreground) == decoded.map(\.foreground)) + #expect(plain.allSatisfy { $0.foregroundPalette == nil && $0.backgroundPalette == nil }) +} + +@Test func rejectsMalformedStylePaletteSections() { + for flags: UInt8 in [0b100, 0x80] { + #expect(throws: TRF1DecodingError("invalid style palette flags")) { + try decodeTRF1StylePalette(paletteSection([(1, flags, 1, 1)])) + } + } + #expect(throws: TRF1DecodingError("style palette count mismatch")) { + try decodeTRF1StylePalette(paletteSection([(1, 1, 1, 0)], itemCount: 2)) + } + var truncated = paletteSection([(1, 1, 1, 0)]).bytes + truncated.removeLast() + #expect(throws: TRF1DecodingError.self) { + try decodeTRF1StylePalette( + TRF1Section(kind: .stylePalette, flags: 0, itemCount: 1, bytes: truncated)) + } + #expect(throws: TRF1DecodingError("wrong style palette section kind")) { + try decodeTRF1StylePalette( + TRF1Section(kind: .styleDefinitions, flags: 0, itemCount: 0, bytes: Data(count: 4))) + } + // A malformed palette fails the style decode that consumes it. + #expect(throws: TRF1DecodingError("invalid style palette flags")) { + try decodeTRF1StyleDefinitions( + styleSection([(1, true, false)]), palette: paletteSection([(1, 0b1000, 1, 0)])) + } +} + +/// The shared golden frame produced by the Rust core and decoded by the +/// TypeScript package, so the Swift decoder is held to the same bytes. +private func ansiBaselineFrame() throws -> Data { + let fixture = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("../../../../native/ghosttea/fixtures/phase1/ansi-baseline.json") + .standardizedFileURL + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: fixture)) + let hex = try #require((object as? [String: Any])?["expectedFrameHex"] as? String) + var bytes = Data(capacity: hex.utf8.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + bytes.append(try #require(UInt8(hex[index..