diff --git a/Cargo.lock b/Cargo.lock index 7d6ccbda..2f81ad10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -56,18 +56,18 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -149,7 +149,7 @@ checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -409,7 +409,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -568,7 +568,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -883,7 +883,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -902,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1600,7 +1600,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1783,6 +1783,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1791,7 +1802,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1844,7 +1855,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1855,7 +1866,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1928,7 +1939,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1977,7 +1988,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2202,7 +2213,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2443,7 +2454,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json b/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json index b657bf24..62d67d4c 100644 --- a/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json +++ b/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "6da56d16f37b315fb22ebe023528c7f4ff8b00b6674613e894838a658c2c83d3" + "value": "7cdc22165195a7f98749dadbf1de0801225a9a3fcac38cfc0a18b931767268e3" }, { "name": "ghosttea:rust-target", diff --git a/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt b/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt index 3065fa48..befc15d2 100644 --- a/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt +++ b/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt @@ -2,7 +2,7 @@ GHOSTTEA iOS THIRD-PARTY NOTICES ================================ This file is generated from the reviewed iOS release dependency graph. -CycloneDX SHA-256: be5713a39ecb1300ec9c5f41db8010e63fe0de4697e9c64f6e3966ac357d9b43 +CycloneDX SHA-256: 9733b5e2f5f8662b650181292deb5e5d130de982ada840cb9e53b5ffaa4f2065 Components: 108 Unique license documents: 97 diff --git a/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json b/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json index 598cfbc4..828fbbbb 100644 --- a/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json +++ b/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json @@ -2,15 +2,15 @@ "schemaVersion": 1, "sourceBom": { "path": "apple/GhostteaKit/Compatibility/ios-release.cdx.json", - "sha256": "be5713a39ecb1300ec9c5f41db8010e63fe0de4697e9c64f6e3966ac357d9b43" + "sha256": "9733b5e2f5f8662b650181292deb5e5d130de982ada840cb9e53b5ffaa4f2065" }, "bundledBom": { "path": "apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json", - "sha256": "be5713a39ecb1300ec9c5f41db8010e63fe0de4697e9c64f6e3966ac357d9b43" + "sha256": "9733b5e2f5f8662b650181292deb5e5d130de982ada840cb9e53b5ffaa4f2065" }, "notices": { "path": "apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt", - "sha256": "ba2189bb8022dd15234bbb6438fea7905df0ae1a8f226121fd89fa501bc06aec", + "sha256": "584197ad95026d1abed13290bf951db8c74db0aaa46afd8f2e64db8820cb143f", "componentCount": 108, "licenseDocumentCount": 97 } diff --git a/apple/GhostteaKit/Compatibility/ios-release.cdx.json b/apple/GhostteaKit/Compatibility/ios-release.cdx.json index b657bf24..62d67d4c 100644 --- a/apple/GhostteaKit/Compatibility/ios-release.cdx.json +++ b/apple/GhostteaKit/Compatibility/ios-release.cdx.json @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "6da56d16f37b315fb22ebe023528c7f4ff8b00b6674613e894838a658c2c83d3" + "value": "7cdc22165195a7f98749dadbf1de0801225a9a3fcac38cfc0a18b931767268e3" }, { "name": "ghosttea:rust-target", diff --git a/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json b/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json index 1e38847e..cb898ae9 100644 --- a/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json +++ b/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json @@ -10,7 +10,7 @@ "pkg:cargo/ghosttea-font-fixture-ffi@0.12.0" ] }, - "cargoLockSha256": "6da56d16f37b315fb22ebe023528c7f4ff8b00b6674613e894838a658c2c83d3", + "cargoLockSha256": "7cdc22165195a7f98749dadbf1de0801225a9a3fcac38cfc0a18b931767268e3", "components": [ { "ref": "pkg:cargo/anyhow@1.0.103", diff --git a/docs/webgpu-appearance-pipeline.md b/docs/webgpu-appearance-pipeline.md index f24104e4..9519bf79 100644 --- a/docs/webgpu-appearance-pipeline.md +++ b/docs/webgpu-appearance-pipeline.md @@ -46,6 +46,30 @@ A solid block cursor is inserted after selection backgrounds and before glyphs; the covered glyph uses `cursor-text`. Bar, underline, and hollow cursors remain late overlay geometry. +## Light adaptation + +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 +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. + +The remap runs in `resolveStyle`, per style and before blending, so glyph +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, +re-keying, or releasing invalidates the persistent scene and geometry cache. + +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 +2031 is set, and sets `COLORFGBG` for new sessions unless the caller provides +one. + ## Catalogs and persistence The linked `ghostty.style` gallery seeds its built-in collection from every diff --git a/native/ghosttea/crates/ghosttea-core/src/model.rs b/native/ghosttea/crates/ghosttea-core/src/model.rs index 342fdc7d..a8228f2a 100644 --- a/native/ghosttea/crates/ghosttea-core/src/model.rs +++ b/native/ghosttea/crates/ghosttea-core/src/model.rs @@ -732,6 +732,37 @@ mod tests { } } + #[test] + fn theme_change_reports_the_new_color_scheme_to_the_program() { + let runtime = Arc::new(TerminalRuntime::discover().unwrap()); + let mut model = TerminalModel::new( + runtime, + TerminalModelOptions { + session_handle: 4, + session_epoch: 1, + layout_epoch: 1, + cols: 20, + rows: 4, + scrollback_bytes: 4096, + }, + ) + .unwrap(); + model.feed(b"\x1b[?2031h", RenderRequest::None).unwrap(); + let update = model + .set_appearance( + [0x4c, 0x4f, 0x69], + [0xef, 0xf1, 0xf5], + [0xdc, 0x8a, 0x78], + &[], + RenderRequest::Full, + ) + .unwrap(); + assert!(update.as_slice().iter().any(|effect| matches!( + effect, + TerminalEffect::WriteToTransport(bytes) if bytes == b"\x1b[?997;2n" + ))); + } + #[test] fn terminal_reply_precedes_semantic_and_render_effects() { let runtime = Arc::new(TerminalRuntime::discover().unwrap()); diff --git a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c index fde9b683..2b8ecc4a 100644 --- a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c +++ b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c @@ -1,4 +1,5 @@ #include "ghostty_shim_internal.h" +#include #include #include @@ -9,6 +10,30 @@ struct EgTrackedSelection { bool rectangle; }; +static double eg_srgb_to_linear(uint8_t value) { + double v = value / 255.0; + return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4); +} + +// A background is light when dark text out-contrasts light text on it, which +// is WCAG relative luminance above ~0.179. +static GhosttyColorScheme eg_scheme_for_background(GhosttyColorRgb background) { + double luminance = 0.2126 * eg_srgb_to_linear(background.r) + + 0.7152 * eg_srgb_to_linear(background.g) + + 0.0722 * eg_srgb_to_linear(background.b); + return (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05) + ? GHOSTTY_COLOR_SCHEME_LIGHT + : GHOSTTY_COLOR_SCHEME_DARK; +} + +static bool eg_color_scheme(GhosttyTerminal terminal, + void* userdata, + GhosttyColorScheme* out_scheme) { + (void)terminal; + *out_scheme = ((EgTerminal*)userdata)->color_scheme; + return true; +} + int eg_terminal_set_colors(EgTerminal* state, uint8_t fg_r, uint8_t fg_g, uint8_t fg_b, uint8_t bg_r, uint8_t bg_g, uint8_t bg_b, @@ -23,8 +48,27 @@ int eg_terminal_set_colors(EgTerminal* state, result = ghostty_terminal_set( state->terminal, GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND, &background); if (result != GHOSTTY_SUCCESS) return result; - return ghostty_terminal_set( + result = ghostty_terminal_set( state->terminal, GHOSTTY_TERMINAL_OPT_COLOR_CURSOR, &cursor); + if (result != GHOSTTY_SUCCESS) return result; + + // Programs that enabled mode 2031 (Claude Code's Auto theme, Neovim) learn + // about a light/dark flip without re-querying. The report rides the normal + // PTY response buffer, so it reaches the program with the next snapshot. + GhosttyColorScheme scheme = eg_scheme_for_background(background); + if (scheme == state->color_scheme) return GHOSTTY_SUCCESS; + state->color_scheme = scheme; + bool reporting = false; + if (ghostty_terminal_mode_get( + state->terminal, GHOSTTY_MODE_COLOR_SCHEME_REPORT, &reporting) != GHOSTTY_SUCCESS || + !reporting) + return GHOSTTY_SUCCESS; + char report[32]; + size_t written = 0; + if (ghostty_color_scheme_report_encode(scheme, report, sizeof report, &written) == + GHOSTTY_SUCCESS) + (void)eg_buffer_append(&state->response, (const uint8_t*)report, written); + return GHOSTTY_SUCCESS; } int eg_terminal_set_palette(EgTerminal* state, @@ -158,6 +202,7 @@ EgTerminal* eg_terminal_new(uint16_t cols, uint16_t rows, size_t max_scrollback) ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, (const void*)eg_title_changed); ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_PWD_CHANGED, (const void*)eg_pwd_changed); ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, (const void*)eg_clipboard_write); + ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_COLOR_SCHEME, (const void*)eg_color_scheme); if (eg_terminal_set_colors( state, 255, 255, 255, diff --git a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h index 037feaff..56248929 100644 --- a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h +++ b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h @@ -28,6 +28,9 @@ struct EgTerminal { EgBuffer grapheme; bool mouse_pressed; uint32_t effects; + // Derived from the embedder's default background; answers CSI ? 996 n and + // drives mode-2031 reports when a theme change flips it. + GhosttyColorScheme color_scheme; }; bool eg_buffer_reserve(EgBuffer* buffer, size_t required); diff --git a/native/ghosttea/crates/ghosttea-vt/src/lib.rs b/native/ghosttea/crates/ghosttea-vt/src/lib.rs index 56e6720b..04b17e39 100644 --- a/native/ghosttea/crates/ghosttea-vt/src/lib.rs +++ b/native/ghosttea/crates/ghosttea-vt/src/lib.rs @@ -2163,6 +2163,42 @@ mod tests { assert_eq!(snapshot.cells[0][3].foreground_palette, Some(4)); } + #[test] + fn answers_color_scheme_queries_from_the_default_background() { + let mut terminal = GhosttyTerminalCore::new(20, 2, 100).unwrap(); + terminal.feed(b"\x1b[?996n"); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;1n"); + + terminal + .set_colors([0x4c, 0x4f, 0x69], [0xef, 0xf1, 0xf5], [0xdc, 0x8a, 0x78]) + .unwrap(); + terminal.feed(b"\x1b[?996n"); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;2n"); + } + + #[test] + fn reports_scheme_flips_only_to_programs_that_enabled_mode_2031() { + let mut terminal = GhosttyTerminalCore::new(20, 2, 100).unwrap(); + let light = ([0x4c, 0x4f, 0x69], [0xef, 0xf1, 0xf5], [0xdc, 0x8a, 0x78]); + let dark = ([0xcd, 0xd6, 0xf4], [0x1e, 0x1e, 0x2e], [0xf5, 0xe0, 0xdc]); + + terminal.set_colors(light.0, light.1, light.2).unwrap(); + assert!(terminal.take_pty_response().is_empty()); + + terminal.feed(b"\x1b[?2031h"); + terminal.set_colors(dark.0, dark.1, dark.2).unwrap(); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;1n"); + + // Another dark theme is not a scheme change. + terminal + .set_colors([0xff, 0xff, 0xff], [0x28, 0x2c, 0x34], [0xff, 0xff, 0xff]) + .unwrap(); + assert!(terminal.take_pty_response().is_empty()); + + terminal.set_colors(light.0, light.1, light.2).unwrap(); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;2n"); + } + #[test] fn distinguishes_defaults_palette_truecolor_and_osc_overrides() { let mut terminal = GhosttyTerminalCore::new(20, 2, 100).unwrap(); diff --git a/native/ghosttea/src/service.rs b/native/ghosttea/src/service.rs index 7d53462b..c6971c8a 100644 --- a/native/ghosttea/src/service.rs +++ b/native/ghosttea/src/service.rs @@ -2235,6 +2235,8 @@ async fn create_local_session( .unwrap_or(terminal_config.scrollback_bytes); let scrollback_bytes = usize::try_from(effective_scrollback_bytes) .context("scrollbackBytes does not fit this platform")?; + let mut options = options; + options.hint_color_scheme(terminal_config.background); tokio::task::spawn_blocking(move || { let session = Session::spawn_configured( options, diff --git a/native/ghosttea/src/session.rs b/native/ghosttea/src/session.rs index 4133a5db..33465530 100644 --- a/native/ghosttea/src/session.rs +++ b/native/ghosttea/src/session.rs @@ -335,6 +335,45 @@ pub struct SpawnOptions { pub scrollback_bytes: Option, } +impl SpawnOptions { + /// Advertise whether the default background is light or dark through + /// `COLORFGBG` (the rxvt `fg;bg` palette-index convention). Programs that + /// read it at startup instead of querying OSC 11 pick a matching theme. + /// A value the caller already set wins. + pub(crate) fn hint_color_scheme(&mut self, background: [u8; 3]) { + let value = if background_is_light(background) { + "0;15" + } else { + "15;0" + }; + let variables = match self.environment.as_mut() { + Some(SessionEnvironment::Inherit { overrides }) => overrides, + Some(SessionEnvironment::Clean { variables }) => variables, + None => &mut self.env, + }; + variables + .entry("COLORFGBG".to_owned()) + .or_insert_with(|| value.to_owned()); + } +} + +/// Matches the VT shim's color-scheme answer: light when dark text +/// out-contrasts light text, i.e. WCAG relative luminance above ~0.179. +fn background_is_light(background: [u8; 3]) -> bool { + let linear = |value: u8| { + let v = f64::from(value) / 255.0; + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + let luminance = 0.2126 * linear(background[0]) + + 0.7152 * linear(background[1]) + + 0.0722 * linear(background[2]); + (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05) +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum KeyAction { @@ -2823,6 +2862,35 @@ mod tests { assert_eq!(maximum.scrollback_bytes, Some(MAX_JSON_SAFE_INTEGER)); } + #[test] + fn color_hint_follows_the_background_and_yields_to_the_caller() { + let options = |environment: serde_json::Value| -> SpawnOptions { + let mut json = spawn_options_json(0.into()); + json["environment"] = environment; + serde_json::from_value(json).unwrap() + }; + let mut light = options(serde_json::json!({ "mode": "clean", "variables": {} })); + light.hint_color_scheme([0xef, 0xf1, 0xf5]); + let Some(SessionEnvironment::Clean { variables }) = &light.environment else { + panic!() + }; + assert_eq!(variables["COLORFGBG"], "0;15"); + + let mut dark: SpawnOptions = serde_json::from_value(spawn_options_json(0.into())).unwrap(); + dark.hint_color_scheme([0x1e, 0x1e, 0x2e]); + assert_eq!(dark.env["COLORFGBG"], "15;0"); + + let mut chosen = options(serde_json::json!({ + "mode": "inherit", + "overrides": { "COLORFGBG": "7;4" } + })); + chosen.hint_color_scheme([0xff, 0xff, 0xff]); + let Some(SessionEnvironment::Inherit { overrides }) = &chosen.environment else { + panic!() + }; + assert_eq!(overrides["COLORFGBG"], "7;4"); + } + #[test] fn scrollback_override_rejects_negative_fractional_and_unsafe_numbers() { for value in [ diff --git a/packages/ghosttea-react/src/config.test.ts b/packages/ghosttea-react/src/config.test.ts index 4e747eb0..0bb73277 100644 --- a/packages/ghosttea-react/src/config.test.ts +++ b/packages/ghosttea-react/src/config.test.ts @@ -27,6 +27,7 @@ describe("shared configuration presentation", () => { selection: [4 / 255, 5 / 255, 6 / 255, 1], selectionForeground: [7 / 255, 8 / 255, 9 / 255, 1], backgroundOpacityCells: false, + lightAdaptation: "auto", }); expect(terminalEffectsFromConfig({ renderer })).toEqual({ postProcess: "better-crt", diff --git a/packages/ghosttea-react/src/config.ts b/packages/ghosttea-react/src/config.ts index a7d18cc7..ee63c1b2 100644 --- a/packages/ghosttea-react/src/config.ts +++ b/packages/ghosttea-react/src/config.ts @@ -23,6 +23,7 @@ export function terminalThemeFromConfig(config: Pick selection: rgba(renderer.selectionBackground), selectionForeground: rgba(renderer.selectionForeground), backgroundOpacityCells: renderer.backgroundOpacityCells ?? false, + lightAdaptation: "auto", }; } diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.test.ts b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts new file mode 100644 index 00000000..0f89eb71 --- /dev/null +++ b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts @@ -0,0 +1,91 @@ +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 { 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]; +const bytes = (color: Rgba): Rgb => [color[0], color[1], color[2]].map((v) => Math.round(v * 255)) as unknown as Rgb; +const near = (actual: Rgba, expected: Rgb, tolerance = 6): void => { + bytes(actual).forEach((value, index) => expect(Math.abs(value - expected[index]!)).toBeLessThanOrEqual(tolerance)); +}; + +// GrokNight's base and GrokDay's paper, with a neutral ink so no tint shifts. +const grok = createLightAdaptation([0x14, 0x14, 0x14], rgba([0xee, 0xee, 0xee]), rgba([0x26, 0x26, 0x26])); + +describe("light adaptation colors", () => { + it("maps the app's base surface onto the theme paper", () => { + near(grok.surface([0x14, 0x14, 0x14]), [0xee, 0xee, 0xee], 1); + }); + + it("keeps the gray hierarchy of GrokNight close to GrokDay", () => { + near(grok.ink([0xe1, 0xe1, 0xe1], grok.base), [0x26, 0x26, 0x26], 10); // primary + near(grok.ink([0x73, 0x73, 0x73], grok.base), [0x74, 0x74, 0x74], 16); // secondary + near(grok.ink([0x33, 0x33, 0x33], grok.base), [0xcd, 0xcd, 0xcd], 8); // border + }); + + it("keeps an accent's hue instead of inverting it", () => { + const [r, g, b] = bytes(grok.ink([0xe0, 0xaf, 0x68], grok.base)); + expect(r).toBeGreaterThan(g); + expect(g).toBeGreaterThan(b); + near(grok.ink([0xe0, 0xaf, 0x68], grok.base), [0xa2, 0x76, 0x12], 14); + }); + + it("keeps a visible tint on dark diff rows", () => { + const claude = createLightAdaptation([0x1e, 0x1e, 0x2e], rgba([0xef, 0xf1, 0xf5]), rgba([0x4c, 0x4f, 0x69])); + const [r, g, b] = bytes(claude.surface([0x3d, 0x01, 0x00])); + expect(r).toBeGreaterThan(240); + expect(r - Math.min(g, b)).toBeGreaterThan(20); + }); + + 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); + }); +}); + +function view(theme: TerminalTheme, cols: number, rows: number, paintedRows: number): RenderView { + const style: StyleDefinition = { + id: 7, + bold: false, + italic: false, + faint: false, + inverse: false, + invisible: false, + strikethrough: false, + underline: false, + background: [0x14, 0x14, 0x14], + }; + const painted: StyleRun[] = [{ styleId: 7, cellStart: 0, cellSpan: cols }]; + return { + cols, + rows: Array(rows).fill(""), + nativeStyleRows: Array.from({ length: rows }, (_, row) => (row < paintedRows ? painted : [])), + styleDefinitions: new Map([[7, style]]), + theme, + } as unknown as RenderView; +} + +const light: TerminalTheme = { + ...DEFAULT_THEME, + background: rgba([0xef, 0xf1, 0xf5]), + foreground: rgba([0x4c, 0x4f, 0x69]), + lightAdaptation: "auto", +}; + +describe("light adaptation engagement", () => { + it("engages for a pane the app paints dark under a light theme", () => { + expect(darkPaintedBase(view(light, 10, 10, 10), false)).toEqual([0x14, 0x14, 0x14]); + }); + + it("stays off for dark themes, opt-outs, and apps that follow the theme", () => { + expect(darkPaintedBase(view({ ...light, background: rgba([0x1e, 0x1e, 0x2e]) }, 10, 10, 10), false)).toBeNull(); + expect(darkPaintedBase(view({ ...light, lightAdaptation: "off" }, 10, 10, 10), false)).toBeNull(); + expect(darkPaintedBase(view(light, 10, 10, 1), false)).toBeNull(); + }); + + it("uses hysteresis so partially painted scrolls do not flicker", () => { + expect(darkPaintedBase(view(light, 10, 10, 5), false)).toBeNull(); + expect(darkPaintedBase(view(light, 10, 10, 5), true)).toEqual([0x14, 0x14, 0x14]); + expect(darkPaintedBase(view(light, 10, 10, 3), true)).toBeNull(); + }); +}); diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.ts b/packages/ghosttea-react/src/renderers/light-adaptation.ts new file mode 100644 index 00000000..39ec5e98 --- /dev/null +++ b/packages/ghosttea-react/src/renderers/light-adaptation.ts @@ -0,0 +1,278 @@ +// 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. +// +// The rules come from comparing candidate remaps with three applications' own +// light designs (GrokDay, Codex, OpenCode): +// +// 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. + +import type { StyleDefinition } from "@vibecook/ghosttea-frame"; +import type { RenderView, Rgba } from "./types.js"; + +export type Rgb = readonly [number, number, number]; +type Lab = [number, number, number]; + +const clamp = (value: number, lo = 0, hi = 1): number => Math.min(hi, Math.max(lo, value)); +const toLinear = (v: number): number => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); +const toGamma = (v: number): number => (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055); + +function oklab([r8, g8, b8]: Rgb): Lab { + const r = toLinear(r8 / 255); + const g = toLinear(g8 / 255); + const b = toLinear(b8 / 255); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.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, + ]; +} + +function oklabToLinear([L, a, b]: Lab): Lab { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + 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, + ]; +} + +const inGamut = (linear: Lab): boolean => linear.every((v) => v >= -1e-4 && v <= 1 + 1e-4); +const fromLch = (L: number, C: number, h: number): Lab => [L, C * Math.cos(h), C * Math.sin(h)]; +const toRgb = (lab: Lab): Rgb => + oklabToLinear(lab).map((v) => Math.round(clamp(toGamma(clamp(v))) * 255)) as unknown as Rgb; + +/** Keep L and hue; reduce chroma until the color fits sRGB. */ +function gamutMap(L: number, C: number, h: number): Rgb { + L = clamp(L); + if (inGamut(oklabToLinear(fromLch(L, C, h)))) return toRgb(fromLch(L, C, h)); + let lo = 0; + let hi = C; + for (let i = 0; i < 20; i += 1) { + const mid = (lo + hi) / 2; + if (inGamut(oklabToLinear(fromLch(L, mid, h)))) lo = mid; + else hi = mid; + } + return toRgb(fromLch(L, lo, h)); +} + +function maxChroma(L: number, h: number): number { + let lo = 0; + let hi = 0.4; + for (let i = 0; i < 16; i += 1) { + const mid = (lo + hi) / 2; + if (inGamut(oklabToLinear(fromLch(L, mid, h)))) lo = mid; + else hi = mid; + } + return lo; +} + +const K1 = 0.206; +const K2 = 0.03; +const K3 = (1 + K1) / (1 + K2); +const toe = (L: number): number => 0.5 * (K3 * L - K1 + Math.sqrt((K3 * L - K1) ** 2 + 4 * K2 * K3 * L)); +const toeInv = (Lr: number): number => (Lr * Lr + K1 * Lr) / (K3 * (Lr + K2)); + +// APCA 0.0.98G-4g. Positive Lc = dark text on a light background. +function apcaY([r, g, b]: Rgb): number { + const y = 0.2126729 * (r / 255) ** 2.4 + 0.7151522 * (g / 255) ** 2.4 + 0.072175 * (b / 255) ** 2.4; + return y < 0.022 ? y + (0.022 - y) ** 1.414 : y; +} +function apca(text: Rgb, background: Rgb): number { + const yt = apcaY(text); + const yb = apcaY(background); + if (Math.abs(yb - yt) < 0.0005) return 0; + if (yb > yt) { + const sapc = (yb ** 0.56 - yt ** 0.57) * 1.14; + return sapc < 0.1 ? 0 : (sapc - 0.027) * 100; + } + const sapc = (yb ** 0.65 - yt ** 0.62) * 1.14; + return sapc > -0.1 ? 0 : (sapc + 0.027) * 100; +} + +const smoothstep = (e0: number, e1: number, x: number): number => { + const t = clamp((x - e0) / (e1 - e0)); + 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), + Math.round(clamp(color[1]) * 255), + Math.round(clamp(color[2]) * 255), +]; +const keyOf = ([r, g, b]: Rgb): number => (r << 16) | (g << 8) | b; + +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; +} + +export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgba): LightAdaptation { + const paper = fromRgba(paperColor); + const inkRgb = fromRgba(inkColor); + const baseLab = oklab(base); + const paperLab = oklab(paper); + const inkLab = oklab(inkRgb); + const baseLr = toe(baseLab[0]); + const paperLr = toe(paperLab[0]); + + const surfaces = new Map(); + const surfaceRgb = (color: Rgb): Rgb => { + const cached = surfaces.get(keyOf(color)); + if (cached) return cached; + const [L, a, b] = oklab(color); + let Lr = clamp(paperLr - (toe(L) - baseLr)); + const ap = a - baseLab[1] + paperLab[1]; + const bp = b - baseLab[2] + paperLab[2]; + const C = Math.hypot(ap, bp); + const h = Math.atan2(bp, ap); + if (C > 0.015) { + const want = Math.min(C, 0.045); + const stop = Lr - 0.15; + while (Lr > stop && maxChroma(toeInv(Lr), h) < want) Lr -= 0.005; + } + const result = gamutMap(toeInv(Lr), C, h); + surfaces.set(keyOf(color), result); + return result; + }; + + const solveLc = (want: number, C: number, h: number, background: Rgb): Rgb => { + const backgroundL = oklab(background)[0]; + let lo = want > 0 ? 0 : backgroundL; + let hi = want > 0 ? backgroundL : 1; + let best = gamutMap(want > 0 ? 0 : 1, C, h); + for (let i = 0; i < 18; i += 1) { + const mid = (lo + hi) / 2; + const candidate = gamutMap(mid, C, h); + const lc = apca(candidate, background); + const enough = want > 0 ? lc >= want : lc <= want; + if (enough) best = candidate; + if (want > 0 === enough) lo = mid; + else hi = mid; + } + return best; + }; + + const inks = new Map(); + const ink = (color: Rgb, sourceBackground: Rgb): Rgba => { + const cacheKey = `${keyOf(color)}/${keyOf(sourceBackground)}`; + const cached = inks.get(cacheKey); + if (cached) return cached; + const mapped = surfaceRgb(sourceBackground); + const [L, a0, b0] = oklab(color); + const C0 = Math.hypot(a0, b0); + // 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. + let a = a0; + let b = b0; + if (C0 < 0.03) { + const w = (1 - C0 / 0.03) * 0.7; + a = a0 * (1 - w) + inkLab[1] * w; + b = b0 * (1 - w) + inkLab[2] * w; + } + const C = Math.hypot(a, b); + const h = Math.atan2(b, a); + const delta = toe(L) - toe(oklab(sourceBackground)[0]); + const sign = delta >= 0 ? -1 : 1; + const Lr = clamp(toe(oklab(mapped)[0]) + sign * Math.max(Math.abs(delta), 0.06)); + let result = gamutMap(toeInv(Lr), C, h); + const w = smoothstep(0.03, 0.08, C0); + if (w > 0) { + const source = apca(color, sourceBackground); + const lightOnDark = source < 0 || (source === 0 && delta >= 0); + const magnitude = clamp(Math.abs(source) * 0.5 + 28, 45, 70); + const boosted = C0 + w * clamp(0.12 - C0, 0, C0 * 0.8); + const accent = solveLc(lightOnDark ? magnitude : -magnitude, boosted, Math.atan2(b0, a0), mapped); + if (w >= 1) result = accent; + else { + const p = oklab(result); + const q = oklab(accent); + result = toRgb([p[0] * (1 - w) + q[0] * w, p[1] * (1 - w) + q[1] * w, p[2] * (1 - w) + q[2] * w]); + } + } + const rgba = toRgba(result); + inks.set(cacheKey, rgba); + return rgba; + }; + + return { + key: [base, paper, inkRgb].map((color) => keyOf(color).toString(16)).join("/"), + base, + surface: (color) => toRgba(surfaceRgb(color)), + ink, + }; +} + +/** 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. */ +export const RELEASE_SHARE = 0.35; + +/** + * The surface a dark-painted application uses as its base, or null 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). + */ +export function darkPaintedBase(view: RenderView, engaged: boolean): Rgb | null { + if ((view.theme.lightAdaptation ?? "off") === "off") return null; + if (!isLightBackground(fromRgba(view.theme.background))) return null; + const rows = Math.max(view.rows.length, view.nativeStyleRows.length); + const total = rows * view.cols; + if (total === 0) return null; + const areas = new Map(); + let dark = 0; + const darkness = new Map(); + for (const runs of view.nativeStyleRows) { + for (const run of runs) { + const style: StyleDefinition | undefined = view.styleDefinitions.get(run.styleId); + const background = style?.inverse ? style.foreground : style?.background; + if (!background) continue; + const key = keyOf(background); + let isDark = darkness.get(key); + if (isDark === undefined) { + isDark = !isLightBackground(background); + darkness.set(key, isDark); + } + if (!isDark) continue; + dark += run.cellSpan; + areas.set(key, (areas.get(key) ?? 0) + run.cellSpan); + } + } + if (dark / total < (engaged ? RELEASE_SHARE : ENGAGE_SHARE)) return null; + let bestKey = 0; + let bestArea = -1; + for (const [key, area] of areas) { + if (area > bestArea) { + bestKey = key; + bestArea = area; + } + } + return [(bestKey >> 16) & 0xff, (bestKey >> 8) & 0xff, bestKey & 0xff]; +} diff --git a/packages/ghosttea-react/src/renderers/types.ts b/packages/ghosttea-react/src/renderers/types.ts index f1561161..30be77d6 100644 --- a/packages/ghosttea-react/src/renderers/types.ts +++ b/packages/ghosttea-react/src/renderers/types.ts @@ -19,6 +19,12 @@ export interface TerminalTheme { selectionForeground: Rgba; /** Apply the window background alpha to explicit cell backgrounds too. */ backgroundOpacityCells?: boolean; + /** + * Under a light theme, remap panes that an application paints dark with its + * own backgrounds (Grok, fixed-theme TUIs) into a light design. "auto" + * engages per pane only when most cells carry an explicit dark background. + */ + lightAdaptation?: "auto" | "off"; } 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 291ac3f7..c2575118 100644 --- a/packages/ghosttea-react/src/renderers/webgpu-renderer.ts +++ b/packages/ghosttea-react/src/renderers/webgpu-renderer.ts @@ -21,6 +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"; const ATLAS_SIZE = 2048; const GEOMETRY_CACHE_LIMIT = 8; @@ -485,6 +486,8 @@ interface WebGpuSurface extends PixelSize { geometryCache: Map; geometryCandidates: Map; sceneValid: boolean; + /** Engaged light adaptation for a dark-painted pane, or null. */ + adaptation: LightAdaptation | null; } interface EffectPass { @@ -548,6 +551,7 @@ function geometryCacheKey( rows: readonly number[], hasNativeRows: boolean, atlasGenerations: readonly [number, number, number], + adaptationKey: string, ): string { const selection = view.selection ? `${view.selection.anchor.row},${view.selection.anchor.column},${view.selection.focus.row},${view.selection.focus.column}` @@ -567,6 +571,7 @@ function geometryCacheKey( ...view.theme.selectionForeground, ].join(","), view.theme.backgroundOpacityCells ? "opacity-cells" : "opaque-cells", + adaptationKey, selection, effectiveCursorStyle(view) === CursorStyle.Block ? `${view.cursor.x},${view.cursor.y}` : "-", atlasGenerations.join(","), @@ -849,26 +854,55 @@ function pushBlockCursorBackground( interface ResolvedStyle { foreground: Rgba; background: Rgba | null; + /** Foreground used as an area fill (block elements); differs only under light adaptation. */ + fill: Rgba; underline: boolean; strikethrough: boolean; invisible: boolean; } -function resolveStyle(style: StyleDefinition | undefined, theme: RenderView["theme"]): ResolvedStyle { +function resolveStyle( + style: StyleDefinition | undefined, + theme: RenderView["theme"], + adaptation: LightAdaptation | null = null, +): ResolvedStyle { let foreground: Rgba = style?.foreground ? rgb(style.foreground) : theme.foreground; let background: Rgba | null = style?.background ? rgb(style.background) : null; - if (style?.inverse) { + let fill = foreground; + if (adaptation) { + // Application colors were designed on the app's dark base; theme-owned + // defaults already belong to the light theme and pass through. + const source = { foreground: style?.foreground, background: style?.background }; + if (style?.inverse) { + background = source.foreground ? adaptation.surface(source.foreground) : theme.foreground; + foreground = source.background + ? adaptation.ink(source.background, source.foreground ?? adaptation.base) + : theme.background; + fill = foreground; + } else { + foreground = source.foreground + ? adaptation.ink(source.foreground, source.background ?? adaptation.base) + : theme.foreground; + background = source.background ? adaptation.surface(source.background) : null; + fill = source.foreground ? adaptation.surface(source.foreground) : theme.foreground; + } + } else if (style?.inverse) { const originalForeground = foreground; foreground = background ?? theme.background; background = originalForeground; + fill = foreground; } if (background && theme.backgroundOpacityCells) { background = [background[0], background[1], background[2], theme.background[3]]; } - if (style?.faint) foreground = [foreground[0], foreground[1], foreground[2], foreground[3] * 0.55]; + 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]; + } return { foreground, background, + fill, underline: style?.underline ?? false, strikethrough: style?.strikethrough ?? false, invisible: style?.invisible ?? false, @@ -1039,6 +1073,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { readonly #monoAtlas: NativeGlyphAtlas; readonly #colorAtlas: NativeGlyphAtlas; readonly #fallbackAtlas: FallbackGlyphAtlas; + readonly #adaptations = new Map(); #performanceMeasurementEnabled = false; private constructor( @@ -1249,6 +1284,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { geometryCache: new Map(), geometryCandidates: new Map(), sceneValid: false, + adaptation: null, }; this.#surfaces.set(id, surface); this.#configure(surface); @@ -1422,9 +1458,32 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { }; } + /** + * Engage, re-key, or release light adaptation for a pane. Any change remaps + * every color, so the persistent scene and cached geometry are invalidated. + */ + #prepareAdaptation(surface: WebGpuSurface, view: RenderView): void { + const base = darkPaintedBase(view, surface.adaptation !== null); + let next: LightAdaptation | null = null; + if (base) { + const key = `${base.join(",")}|${view.theme.background.join(",")}|${view.theme.foreground.join(",")}`; + next = this.#adaptations.get(key) ?? null; + if (!next) { + next = createLightAdaptation(base, view.theme.background, view.theme.foreground); + this.#adaptations.set(key, next); + if (this.#adaptations.size > 8) this.#adaptations.delete(this.#adaptations.keys().next().value!); + } + } + if ((next?.key ?? null) === (surface.adaptation?.key ?? null)) return; + surface.adaptation = next; + surface.sceneValid = false; + clearGeometryCache(surface); + } + render(id: string, view: RenderView): TerminalRenderMetrics | undefined { const surface = this.#surfaces.get(id); if (!surface) return; + this.#prepareAdaptation(surface, view); const encoder = this.device.createCommandEncoder({ label: `terminal frame ${id}` }); const metrics = view.damage?.geometryChanged === false @@ -1441,6 +1500,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { const encoder = this.device.createCommandEncoder({ label: `terminal frame batch (${active.length} panes)` }); const byId = new Map(); for (const { id, view } of active) { + this.#prepareAdaptation(this.#surfaces.get(id)!, view); byId.set( id, view.damage?.geometryChanged === false @@ -1463,6 +1523,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { scale: number, viewportWidth: number, viewportHeight: number, + adaptation: LightAdaptation | null, ): CpuGeometry { const rectangleVertices: number[] = []; const glyphVertices: number[] = []; @@ -1472,7 +1533,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { const styleFor = (styleId: number): ResolvedStyle => { const cached = resolvedStyles.get(styleId); if (cached) return cached; - const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme); + const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, adaptation); resolvedStyles.set(styleId, resolved); return resolved; }; @@ -1596,6 +1657,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { if (blockElement) { let backdrop = style.background ?? view.theme.background; if (isSelected) backdrop = view.theme.selection; + const blockForeground = foreground === style.foreground ? style.fill : foreground; if ( pushBlockElement( rectangleVertices, @@ -1603,7 +1665,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { instance.cellStart, row, scale, - over(foreground, backdrop), + over(blockForeground, backdrop), viewportWidth, viewportHeight, ) @@ -1727,7 +1789,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { const styleFor = (styleId: number): ResolvedStyle => { const cached = resolvedStyles.get(styleId); if (cached) return cached; - const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme); + const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, surface.adaptation); resolvedStyles.set(styleId, resolved); return resolved; }; @@ -1854,7 +1916,8 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { if (blockElement) { let backdrop = style.background ?? view.theme.background; if (isSelected) backdrop = view.theme.selection; - const blockColor = over(foreground, backdrop); + const blockForeground = foreground === style.foreground ? style.fill : foreground; + const blockColor = over(blockForeground, backdrop); if ( pushBlockElement( rectangleVertices, @@ -2155,7 +2218,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { this.#colorAtlas.generation, this.#fallbackAtlas.generation, ]; - let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations()); + let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations(), surface.adaptation?.key ?? "-"); const cached = surface.geometryCache.get(key); const cacheHit = cached !== undefined; if (cached) { @@ -2174,8 +2237,9 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { scale, viewportWidth, viewportHeight, + surface.adaptation, ); - key = geometryCacheKey(view, damage.rows, hasNativeRows, generations()); + key = geometryCacheKey(view, damage.rows, hasNativeRows, generations(), surface.adaptation?.key ?? "-"); const promote = admitted && key === candidateKey; geometry = { rectangleBuffer: promote