feat(tui): add inherited terminal theme
- commit
- 25773e133dfdbda00ea2faf1993f2831b45988ca
- author
- brice · · 2026-08-05 08:57 UTC
- parent
- 1513f55
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +866 -86
M README.md +1 -1
| @@ -419,7 +419,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 419 | 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | |
| 420 | 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | |
| 421 | 421 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | |
| 422 | - | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | |
| 422 | + | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Inherited mode uses terminal colors with truecolor or best-effort 256-color output; theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | |
| 423 | 423 | - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running. | |
| 424 | 424 | ||
| 425 | 425 | ### `/skills` | |
| @@ -419,7 +419,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | ||
| 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | ||
| 421 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | 421 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | ||
| 422 | - | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | 422 | + | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Inherited mode uses terminal colors with truecolor or best-effort 256-color output; theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). |
| 423 | - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running. | 423 | - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running. | ||
| 424 | 424 | ||||
| 425 | ### `/skills` | 425 | ### `/skills` | ||
M docs/themes.md +20 -1
| @@ -19,6 +19,21 @@ saved in `$ZOT_HOME/config.json` and apply immediately. If the selected | |||
| 19 | 19 | file is deleted later, zot resets the setting to the built-in auto | |
| 20 | 20 | (default) theme. | |
| 21 | 21 | ||
| 22 | + | The built-in choices are: | |
| 23 | + | ||
| 24 | + | - **auto** — detect the terminal background and use zot's built-in dark or | |
| 25 | + | light palette. This remains the default. | |
| 26 | + | - **inherited (from terminal)** — use the terminal's reported foreground, | |
| 27 | + | background, and ANSI palette. When `COLORTERM=truecolor`/`24bit` or | |
| 28 | + | `TERM` ends in `-direct`, zot emits truecolor; otherwise it quantizes | |
| 29 | + | derived colors to the xterm-256 palette as a best effort. Terminals that | |
| 30 | + | do not answer OSC color queries fall back to zot's built-in palette while | |
| 31 | + | still using the advertised color depth. | |
| 32 | + | - **dark** / **light** — use zot's fixed built-in palettes. | |
| 33 | + | ||
| 34 | + | The inherited terminal snapshot is captured at startup and reused when the | |
| 35 | + | setting is changed live; restart zot to pick up terminal palette changes. | |
| 36 | + | ||
| 22 | 37 | Theme files bundled with extensions are discovered in-place from loaded | |
| 23 | 38 | extension directories: | |
| 24 | 39 | ||
| @@ -176,7 +191,11 @@ You may also put overrides directly at the top level or directly under | |||
| 176 | 191 | ||
| 177 | 192 | ## Color fields | |
| 178 | 193 | ||
| 179 | - | Most color fields are xterm-256 indexes (`0`–`255`). | |
| 194 | + | Most color fields are xterm-256 indexes (`0`–`255`) for JSON themes. | |
| 195 | + | The built-in inherited theme resolves semantic accents through the terminal's | |
| 196 | + | ANSI palette, fades muted/selected surfaces toward the terminal background, | |
| 197 | + | and emits truecolor or best-effort 256-color sequences according to terminal | |
| 198 | + | capability. | |
| 180 | 199 | ||
| 181 | 200 | - `fg` — default foreground text. | |
| 182 | 201 | - `muted` — secondary text, dividers, gutters, inactive hints. | |
| @@ -19,6 +19,21 @@ saved in `$ZOT_HOME/config.json` and apply immediately. If the selected | |||||
| 19 | file is deleted later, zot resets the setting to the built-in auto | 19 | file is deleted later, zot resets the setting to the built-in auto | ||
| 20 | (default) theme. | 20 | (default) theme. | ||
| 21 | 21 | ||||
| 22 | + | The built-in choices are: | |||
| 23 | + | ||||
| 24 | + | - **auto** — detect the terminal background and use zot's built-in dark or | |||
| 25 | + | light palette. This remains the default. | |||
| 26 | + | - **inherited (from terminal)** — use the terminal's reported foreground, | |||
| 27 | + | background, and ANSI palette. When `COLORTERM=truecolor`/`24bit` or | |||
| 28 | + | `TERM` ends in `-direct`, zot emits truecolor; otherwise it quantizes | |||
| 29 | + | derived colors to the xterm-256 palette as a best effort. Terminals that | |||
| 30 | + | do not answer OSC color queries fall back to zot's built-in palette while | |||
| 31 | + | still using the advertised color depth. | |||
| 32 | + | - **dark** / **light** — use zot's fixed built-in palettes. | |||
| 33 | + | ||||
| 34 | + | The inherited terminal snapshot is captured at startup and reused when the | |||
| 35 | + | setting is changed live; restart zot to pick up terminal palette changes. | |||
| 36 | + | ||||
| 22 | Theme files bundled with extensions are discovered in-place from loaded | 37 | Theme files bundled with extensions are discovered in-place from loaded | ||
| 23 | extension directories: | 38 | extension directories: | ||
| 24 | 39 | ||||
| @@ -176,7 +191,11 @@ You may also put overrides directly at the top level or directly under | |||||
| 176 | 191 | ||||
| 177 | ## Color fields | 192 | ## Color fields | ||
| 178 | 193 | ||||
| 179 | - | Most color fields are xterm-256 indexes (`0`–`255`). | 194 | + | Most color fields are xterm-256 indexes (`0`–`255`) for JSON themes. |
| 195 | + | The built-in inherited theme resolves semantic accents through the terminal's | |||
| 196 | + | ANSI palette, fades muted/selected surfaces toward the terminal background, | |||
| 197 | + | and emits truecolor or best-effort 256-color sequences according to terminal | |||
| 198 | + | capability. | |||
| 180 | 199 | ||||
| 181 | - `fg` — default foreground text. | 200 | - `fg` — default foreground text. | ||
| 182 | - `muted` — secondary text, dividers, gutters, inactive hints. | 201 | - `muted` — secondary text, dividers, gutters, inactive hints. | ||
M packages/agent/modes/interactive.go +5 -1
| @@ -4118,7 +4118,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4118 | 4118 | { | |
| 4119 | 4119 | key: "theme", | |
| 4120 | 4120 | label: "color theme", | |
| 4121 | - | desc: "choose a theme from $ZOT_HOME/themes or a loaded extension", | |
| 4121 | + | desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file", | |
| 4122 | 4122 | options: themeOptions, | |
| 4123 | 4123 | choice: themeChoice, | |
| 4124 | 4124 | }, | |
| @@ -4820,6 +4820,10 @@ func (i *Interactive) applyThemeNow(name string) { | |||
| 4820 | 4820 | if tui.IsLightTheme(i.cfg.Theme) { | |
| 4821 | 4821 | detected = tui.Light | |
| 4822 | 4822 | } | |
| 4823 | + | // Keep the startup terminal snapshot available when a user switches | |
| 4824 | + | // themes live; inherited mode must not issue OSC queries while the TUI | |
| 4825 | + | // already owns raw stdin. | |
| 4826 | + | detected.Terminal = i.cfg.Theme.Terminal | |
| 4823 | 4827 | th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected) | |
| 4824 | 4828 | if err != nil { | |
| 4825 | 4829 | if i.cfg.SettingsStore != nil { | |
| @@ -4118,7 +4118,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 4118 | { | 4118 | { | ||
| 4119 | key: "theme", | 4119 | key: "theme", | ||
| 4120 | label: "color theme", | 4120 | label: "color theme", | ||
| 4121 | - | desc: "choose a theme from $ZOT_HOME/themes or a loaded extension", | 4121 | + | desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file", |
| 4122 | options: themeOptions, | 4122 | options: themeOptions, | ||
| 4123 | choice: themeChoice, | 4123 | choice: themeChoice, | ||
| 4124 | }, | 4124 | }, | ||
| @@ -4820,6 +4820,10 @@ func (i *Interactive) applyThemeNow(name string) { | |||||
| 4820 | if tui.IsLightTheme(i.cfg.Theme) { | 4820 | if tui.IsLightTheme(i.cfg.Theme) { | ||
| 4821 | detected = tui.Light | 4821 | detected = tui.Light | ||
| 4822 | } | 4822 | } | ||
| 4823 | + | // Keep the startup terminal snapshot available when a user switches | |||
| 4824 | + | // themes live; inherited mode must not issue OSC queries while the TUI | |||
| 4825 | + | // already owns raw stdin. | |||
| 4826 | + | detected.Terminal = i.cfg.Theme.Terminal | |||
| 4823 | th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected) | 4827 | th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected) | ||
| 4824 | if err != nil { | 4828 | if err != nil { | ||
| 4825 | if i.cfg.SettingsStore != nil { | 4829 | if i.cfg.SettingsStore != nil { | ||
A packages/agent/modes/theme_setting_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSettingsDialogOffersInheritedTheme(t *testing.T) { | |
| 10 | + | interactive := NewInteractive(InteractiveConfig{}) | |
| 11 | + | interactive.openSettingsDialog() | |
| 12 | + | ||
| 13 | + | for _, item := range interactive.settingsDialog.items { | |
| 14 | + | if item.key != "theme" { | |
| 15 | + | continue | |
| 16 | + | } | |
| 17 | + | for _, option := range item.options { | |
| 18 | + | if option.value == "inherited" { | |
| 19 | + | if option.label != "inherited (from terminal)" { | |
| 20 | + | t.Fatalf("inherited label = %q, want inherited (from terminal)", option.label) | |
| 21 | + | } | |
| 22 | + | return | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | t.Fatal("theme picker did not include inherited") | |
| 26 | + | } | |
| 27 | + | t.Fatal("settings dialog did not include theme picker") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) { | |
| 31 | + | detected := tui.Dark | |
| 32 | + | detected.Terminal = tui.TerminalProfile{ | |
| 33 | + | Foreground: tui.ColorRGB(220, 220, 220), | |
| 34 | + | Background: tui.ColorRGB(10, 10, 10), | |
| 35 | + | HasForeground: true, | |
| 36 | + | HasBackground: true, | |
| 37 | + | TrueColor: true, | |
| 38 | + | } | |
| 39 | + | interactive := NewInteractive(InteractiveConfig{Theme: detected, ThemeName: "auto"}) | |
| 40 | + | interactive.rend = nil | |
| 41 | + | interactive.applyThemeNow("inherited") | |
| 42 | + | ||
| 43 | + | if !interactive.cfg.Theme.Inherited { | |
| 44 | + | t.Fatal("applying inherited theme did not switch the live theme") | |
| 45 | + | } | |
| 46 | + | if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor { | |
| 47 | + | t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode) | |
| 48 | + | } | |
| 49 | + | if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground { | |
| 50 | + | t.Fatal("live inherited theme lost the captured terminal profile") | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestSettingsDialogOffersInheritedTheme(t *testing.T) { | |||
| 10 | + | interactive := NewInteractive(InteractiveConfig{}) | |||
| 11 | + | interactive.openSettingsDialog() | |||
| 12 | + | ||||
| 13 | + | for _, item := range interactive.settingsDialog.items { | |||
| 14 | + | if item.key != "theme" { | |||
| 15 | + | continue | |||
| 16 | + | } | |||
| 17 | + | for _, option := range item.options { | |||
| 18 | + | if option.value == "inherited" { | |||
| 19 | + | if option.label != "inherited (from terminal)" { | |||
| 20 | + | t.Fatalf("inherited label = %q, want inherited (from terminal)", option.label) | |||
| 21 | + | } | |||
| 22 | + | return | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | t.Fatal("theme picker did not include inherited") | |||
| 26 | + | } | |||
| 27 | + | t.Fatal("settings dialog did not include theme picker") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) { | |||
| 31 | + | detected := tui.Dark | |||
| 32 | + | detected.Terminal = tui.TerminalProfile{ | |||
| 33 | + | Foreground: tui.ColorRGB(220, 220, 220), | |||
| 34 | + | Background: tui.ColorRGB(10, 10, 10), | |||
| 35 | + | HasForeground: true, | |||
| 36 | + | HasBackground: true, | |||
| 37 | + | TrueColor: true, | |||
| 38 | + | } | |||
| 39 | + | interactive := NewInteractive(InteractiveConfig{Theme: detected, ThemeName: "auto"}) | |||
| 40 | + | interactive.rend = nil | |||
| 41 | + | interactive.applyThemeNow("inherited") | |||
| 42 | + | ||||
| 43 | + | if !interactive.cfg.Theme.Inherited { | |||
| 44 | + | t.Fatal("applying inherited theme did not switch the live theme") | |||
| 45 | + | } | |||
| 46 | + | if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor { | |||
| 47 | + | t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode) | |||
| 48 | + | } | |||
| 49 | + | if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground { | |||
| 50 | + | t.Fatal("live inherited theme lost the captured terminal profile") | |||
| 51 | + | } | |||
| 52 | + | } | |||
M packages/agent/settings_store_test.go +18 -0
| @@ -161,3 +161,21 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |||
| 161 | 161 | t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings") | |
| 162 | 162 | } | |
| 163 | 163 | } | |
| 164 | + | ||
| 165 | + | func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) { | |
| 166 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 167 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if err := (configSettingsStore{}).SetTheme("inherited"); err != nil { | |
| 172 | + | t.Fatal(err) | |
| 173 | + | } | |
| 174 | + | cfg, err := LoadConfig() | |
| 175 | + | if err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | if cfg.Theme != "inherited" { | |
| 179 | + | t.Fatalf("theme = %q, want inherited", cfg.Theme) | |
| 180 | + | } | |
| 181 | + | } | |
| @@ -161,3 +161,21 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |||||
| 161 | t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings") | 161 | t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings") | ||
| 162 | } | 162 | } | ||
| 163 | } | 163 | } | ||
| 164 | + | ||||
| 165 | + | func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) { | |||
| 166 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 167 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 168 | + | t.Fatal(err) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | if err := (configSettingsStore{}).SetTheme("inherited"); err != nil { | |||
| 172 | + | t.Fatal(err) | |||
| 173 | + | } | |||
| 174 | + | cfg, err := LoadConfig() | |||
| 175 | + | if err != nil { | |||
| 176 | + | t.Fatal(err) | |||
| 177 | + | } | |||
| 178 | + | if cfg.Theme != "inherited" { | |||
| 179 | + | t.Fatalf("theme = %q, want inherited", cfg.Theme) | |||
| 180 | + | } | |||
| 181 | + | } | |||
M packages/agent/skills/builtin/write-zot-themes/SKILL.md +5 -0
| @@ -17,6 +17,11 @@ nothing is required. A file may contain only colors, only spinner | |||
| 17 | 17 | settings, only syntax colors, or only metadata. Missing values always | |
| 18 | 18 | inherit from the built-in detected light/dark default. | |
| 19 | 19 | ||
| 20 | + | The `/settings` picker also provides an **inherited (from terminal)** | |
| 21 | + | built-in theme. It uses the terminal's reported colors and emits truecolor | |
| 22 | + | when advertised, with a best-effort xterm-256 fallback otherwise. JSON theme | |
| 23 | + | files remain explicit zot palettes and use the color forms documented below. | |
| 24 | + | ||
| 20 | 25 | User themes are discovered from: | |
| 21 | 26 | ||
| 22 | 27 | ```text | |
| @@ -17,6 +17,11 @@ nothing is required. A file may contain only colors, only spinner | |||||
| 17 | settings, only syntax colors, or only metadata. Missing values always | 17 | settings, only syntax colors, or only metadata. Missing values always | ||
| 18 | inherit from the built-in detected light/dark default. | 18 | inherit from the built-in detected light/dark default. | ||
| 19 | 19 | ||||
| 20 | + | The `/settings` picker also provides an **inherited (from terminal)** | |||
| 21 | + | built-in theme. It uses the terminal's reported colors and emits truecolor | |||
| 22 | + | when advertised, with a best-effort xterm-256 fallback otherwise. JSON theme | |||
| 23 | + | files remain explicit zot palettes and use the color forms documented below. | |||
| 24 | + | ||||
| 20 | User themes are discovered from: | 25 | User themes are discovered from: | ||
| 21 | 26 | ||||
| 22 | ```text | 27 | ```text | ||
M packages/tui/detect_bg.go +205 -54
| @@ -9,71 +9,229 @@ import ( | |||
| 9 | 9 | "golang.org/x/term" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | // DetectThemeFromBackground queries the controlling tty for its | |
| 13 | - | // current background colour using the OSC 11 escape sequence and | |
| 14 | - | // returns Dark or Light based on the response's perceived | |
| 15 | - | // luminance. Falls back to Dark when the terminal does not | |
| 16 | - | // reply within timeout, which is the expected behaviour for | |
| 17 | - | // terminals that do not implement OSC 11 (Linux console, VS Code's | |
| 18 | - | // integrated terminal in some configurations, tmux without | |
| 19 | - | // pass-through, very old emulators). | |
| 12 | + | // DetectTrueColor reports whether TERM or COLORTERM advertises direct color | |
| 13 | + | // support. This follows the same conservative signals used by ../vev. | |
| 14 | + | func DetectTrueColor(termEnv, colorTerm string) bool { | |
| 15 | + | switch strings.ToLower(strings.TrimSpace(colorTerm)) { | |
| 16 | + | case "truecolor", "24bit": | |
| 17 | + | return true | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | termEnv = strings.ToLower(strings.TrimSpace(termEnv)) | |
| 21 | + | return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // DetectThemeFromBackground queries the controlling tty for its current | |
| 25 | + | // foreground, background, and ANSI palette using OSC 10/11/4. The returned | |
| 26 | + | // theme keeps the detected light/dark built-in colors for the normal auto | |
| 27 | + | // theme and carries the terminal snapshot for the inherited theme. | |
| 20 | 28 | // | |
| 21 | - | // The query / parse runs synchronously before the TUI is | |
| 22 | - | // initialised so the returned theme can drive the entire session. | |
| 23 | - | // We briefly put stdin into raw mode and disable echo so the OSC | |
| 24 | - | // reply doesn't leak onto the user's screen as visible bytes. | |
| 29 | + | // The query / parse runs synchronously before the TUI is initialised so the | |
| 30 | + | // returned snapshot can drive the entire session. We briefly put stdin into | |
| 31 | + | // raw mode and disable echo so OSC replies do not leak onto the user's screen. | |
| 25 | 32 | func DetectThemeFromBackground(timeout time.Duration) Theme { | |
| 26 | - | // Honour explicit override env var first; some users / CI envs | |
| 27 | - | // know better than the heuristic. | |
| 33 | + | profile := detectTerminalProfile(timeout) | |
| 34 | + | base := Dark | |
| 35 | + | ||
| 36 | + | // Honour explicit override env vars first; some users and CI environments | |
| 37 | + | // know better than the heuristic. The terminal snapshot is still retained | |
| 38 | + | // so selecting the inherited theme later remains useful. | |
| 28 | 39 | switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) { | |
| 29 | 40 | case "dark": | |
| 30 | - | return Dark | |
| 41 | + | base = Dark | |
| 31 | 42 | case "light": | |
| 32 | - | return Light | |
| 43 | + | base = Light | |
| 44 | + | default: | |
| 45 | + | if profile.HasBackground { | |
| 46 | + | luma := 0.2126*float64(profile.Background.R) + | |
| 47 | + | 0.7152*float64(profile.Background.G) + | |
| 48 | + | 0.0722*float64(profile.Background.B) | |
| 49 | + | if luma >= 127.5 { | |
| 50 | + | base = Light | |
| 51 | + | } | |
| 52 | + | } | |
| 33 | 53 | } | |
| 34 | 54 | ||
| 35 | - | if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { | |
| 36 | - | return Dark | |
| 55 | + | base.Terminal = profile | |
| 56 | + | return base | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type oscColorResponse struct { | |
| 60 | + | kind int | |
| 61 | + | slot int | |
| 62 | + | color TerminalColor | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func detectTerminalProfile(timeout time.Duration) TerminalProfile { | |
| 66 | + | profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))} | |
| 67 | + | if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 { | |
| 68 | + | return profile | |
| 37 | 69 | } | |
| 38 | 70 | ||
| 39 | 71 | fd := int(os.Stdin.Fd()) | |
| 40 | 72 | st, err := term.MakeRaw(fd) | |
| 41 | 73 | if err != nil { | |
| 42 | - | return Dark | |
| 74 | + | return profile | |
| 43 | 75 | } | |
| 44 | 76 | defer term.Restore(fd, st) | |
| 45 | 77 | ||
| 46 | - | // Send the query. ST (\x1b\\) and BEL (\x07) are both accepted | |
| 47 | - | // terminators; some terminals only honour one of them, so we | |
| 48 | - | // send BEL which is more widely supported. | |
| 49 | - | if _, err := os.Stdout.Write([]byte("\x1b]11;?\x07")); err != nil { | |
| 50 | - | return Dark | |
| 78 | + | queries := terminalColorQueries() | |
| 79 | + | if _, err := os.Stdout.Write([]byte(queries)); err != nil { | |
| 80 | + | return profile | |
| 51 | 81 | } | |
| 52 | 82 | ||
| 53 | - | deadline := time.Now().Add(timeout) | |
| 54 | - | resp := readOSCResponse(deadline) | |
| 55 | - | if resp == "" { | |
| 56 | - | return Dark | |
| 83 | + | responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount) | |
| 84 | + | for _, response := range responses { | |
| 85 | + | switch response.kind { | |
| 86 | + | case 10: | |
| 87 | + | profile.Foreground = response.color | |
| 88 | + | profile.HasForeground = true | |
| 89 | + | case 11: | |
| 90 | + | profile.Background = response.color | |
| 91 | + | profile.HasBackground = true | |
| 92 | + | case 4: | |
| 93 | + | if response.slot >= 0 && response.slot < len(profile.Palette) { | |
| 94 | + | profile.Palette[response.slot] = response.color | |
| 95 | + | profile.PaletteKnown |= uint16(1) << response.slot | |
| 96 | + | } | |
| 97 | + | } | |
| 57 | 98 | } | |
| 99 | + | if profile.HasBackground { | |
| 100 | + | luma := 0.2126*float64(profile.Background.R) + | |
| 101 | + | 0.7152*float64(profile.Background.G) + | |
| 102 | + | 0.0722*float64(profile.Background.B) | |
| 103 | + | profile.Light = luma >= 127.5 | |
| 104 | + | profile.SchemeKnown = true | |
| 105 | + | } | |
| 106 | + | return profile | |
| 107 | + | } | |
| 58 | 108 | ||
| 59 | - | r, g, b, ok := parseOSC11Reply(resp) | |
| 60 | - | if !ok { | |
| 61 | - | return Dark | |
| 109 | + | const ( | |
| 110 | + | terminalColorQueryCount = 18 | |
| 111 | + | terminalColorResponseLimit = 64 | |
| 112 | + | ) | |
| 113 | + | ||
| 114 | + | func terminalColorQueries() string { | |
| 115 | + | var b strings.Builder | |
| 116 | + | b.Grow(terminalColorQueryCount * 12) | |
| 117 | + | b.WriteString("\x1b]10;?\x07\x1b]11;?\x07") | |
| 118 | + | for slot := 0; slot < 16; slot++ { | |
| 119 | + | b.WriteString("\x1b]4;") | |
| 120 | + | b.WriteString(itoa(slot)) | |
| 121 | + | b.WriteString(";?\x07") | |
| 62 | 122 | } | |
| 123 | + | return b.String() | |
| 124 | + | } | |
| 63 | 125 | ||
| 64 | - | // Rec. 709 luma. Threshold at 0.5: anything brighter than | |
| 65 | - | // mid-grey gets the light theme. | |
| 66 | - | luma := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b) | |
| 67 | - | if luma >= 0.5 { | |
| 68 | - | return Light | |
| 126 | + | // readOSCColorResponses drains terminal replies until all expected replies | |
| 127 | + | // arrive, the deadline expires, or stdin hits EOF. It only consumes complete | |
| 128 | + | // BEL/ST-terminated OSC color responses; an incomplete tail is discarded at | |
| 129 | + | // timeout so a failed query cannot poison the next interactive read. | |
| 130 | + | func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse { | |
| 131 | + | responses := make([]oscColorResponse, 0, expected) | |
| 132 | + | buf := make([]byte, 0, 64) | |
| 133 | + | for len(responses) < expected && time.Now().Before(deadline) { | |
| 134 | + | remaining := time.Until(deadline) | |
| 135 | + | if remaining <= 0 { | |
| 136 | + | break | |
| 137 | + | } | |
| 138 | + | b, ok, err := peekStdin(os.Stdin, remaining) | |
| 139 | + | if err != nil || !ok { | |
| 140 | + | break | |
| 141 | + | } | |
| 142 | + | if len(buf) >= terminalColorResponseLimit { | |
| 143 | + | buf = buf[:0] | |
| 144 | + | } | |
| 145 | + | buf = append(buf, b) | |
| 146 | + | if b != '\x07' && !(len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\') { | |
| 147 | + | continue | |
| 148 | + | } | |
| 149 | + | if response, ok := parseOSCColorResponse(buf); ok { | |
| 150 | + | responses = append(responses, response) | |
| 151 | + | } | |
| 152 | + | buf = buf[:0] | |
| 153 | + | } | |
| 154 | + | return responses | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) { | |
| 158 | + | s := string(raw) | |
| 159 | + | s = strings.TrimSuffix(s, "\x07") | |
| 160 | + | s = strings.TrimSuffix(s, "\x1b\\") | |
| 161 | + | ||
| 162 | + | if strings.HasPrefix(s, "\x1b]10;") { | |
| 163 | + | color, ok := parseTerminalRGB(s[len("\x1b]10;"):]) | |
| 164 | + | return oscColorResponse{kind: 10, color: color}, ok | |
| 165 | + | } | |
| 166 | + | if strings.HasPrefix(s, "\x1b]11;") { | |
| 167 | + | color, ok := parseTerminalRGB(s[len("\x1b]11;"):]) | |
| 168 | + | return oscColorResponse{kind: 11, color: color}, ok | |
| 169 | + | } | |
| 170 | + | if strings.HasPrefix(s, "\x1b]4;") { | |
| 171 | + | body := s[len("\x1b]4;"):] | |
| 172 | + | parts := strings.SplitN(body, ";", 2) | |
| 173 | + | if len(parts) != 2 || parts[0] == "" { | |
| 174 | + | return oscColorResponse{}, false | |
| 175 | + | } | |
| 176 | + | slot, err := strconv.Atoi(parts[0]) | |
| 177 | + | if err != nil || slot < 0 || slot >= 16 { | |
| 178 | + | return oscColorResponse{}, false | |
| 179 | + | } | |
| 180 | + | color, ok := parseTerminalRGB(parts[1]) | |
| 181 | + | return oscColorResponse{kind: 4, slot: slot, color: color}, ok | |
| 182 | + | } | |
| 183 | + | return oscColorResponse{}, false | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func parseTerminalRGB(value string) (TerminalColor, bool) { | |
| 187 | + | value = strings.TrimSpace(value) | |
| 188 | + | if strings.HasPrefix(value, "#") { | |
| 189 | + | if len(value) != 7 { | |
| 190 | + | return TerminalColor{}, false | |
| 191 | + | } | |
| 192 | + | r, ok := parseHexByte(value[1:3]) | |
| 193 | + | if !ok { | |
| 194 | + | return TerminalColor{}, false | |
| 195 | + | } | |
| 196 | + | g, ok := parseHexByte(value[3:5]) | |
| 197 | + | if !ok { | |
| 198 | + | return TerminalColor{}, false | |
| 199 | + | } | |
| 200 | + | b, ok := parseHexByte(value[5:7]) | |
| 201 | + | if !ok { | |
| 202 | + | return TerminalColor{}, false | |
| 203 | + | } | |
| 204 | + | return ColorRGB(r, g, b), true | |
| 205 | + | } | |
| 206 | + | if !strings.HasPrefix(value, "rgb:") { | |
| 207 | + | return TerminalColor{}, false | |
| 208 | + | } | |
| 209 | + | parts := strings.Split(value[len("rgb:"):], "/") | |
| 210 | + | if len(parts) != 3 { | |
| 211 | + | return TerminalColor{}, false | |
| 212 | + | } | |
| 213 | + | var channels [3]int | |
| 214 | + | for i, part := range parts { | |
| 215 | + | if len(part) < 1 || len(part) > 4 { | |
| 216 | + | return TerminalColor{}, false | |
| 217 | + | } | |
| 218 | + | v, err := strconv.ParseUint(part, 16, 16) | |
| 219 | + | if err != nil { | |
| 220 | + | return TerminalColor{}, false | |
| 221 | + | } | |
| 222 | + | max := uint64(1) | |
| 223 | + | for range part { | |
| 224 | + | max *= 16 | |
| 225 | + | } | |
| 226 | + | max-- | |
| 227 | + | channels[i] = int(v * 255 / max) | |
| 69 | 228 | } | |
| 70 | - | return Dark | |
| 229 | + | return ColorRGB(channels[0], channels[1], channels[2]), true | |
| 71 | 230 | } | |
| 72 | 231 | ||
| 73 | - | // readOSCResponse drains stdin into a small buffer until either a | |
| 74 | - | // terminator (BEL or ST) is seen, the deadline expires, or stdin | |
| 75 | - | // hits EOF. Returns whatever was collected, or "" on no usable | |
| 76 | - | // response. | |
| 232 | + | // readOSCResponse drains stdin into a small buffer until either a terminator | |
| 233 | + | // (BEL or ST) is seen, the deadline expires, or stdin hits EOF. It remains a | |
| 234 | + | // small helper for callers that only need one OSC reply. | |
| 77 | 235 | func readOSCResponse(deadline time.Time) string { | |
| 78 | 236 | var buf [128]byte | |
| 79 | 237 | n := 0 | |
| @@ -90,12 +248,9 @@ func readOSCResponse(deadline time.Time) string { | |||
| 90 | 248 | buf[n] = b | |
| 91 | 249 | n++ | |
| 92 | 250 | } | |
| 93 | - | // BEL terminator | |
| 94 | 251 | if b == 0x07 { | |
| 95 | 252 | return string(buf[:n]) | |
| 96 | 253 | } | |
| 97 | - | // ST terminator: ESC then '\\'. We saw it the moment the | |
| 98 | - | // previous byte was ESC and the current byte is '\\'. | |
| 99 | 254 | if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' { | |
| 100 | 255 | return string(buf[:n]) | |
| 101 | 256 | } | |
| @@ -103,19 +258,16 @@ func readOSCResponse(deadline time.Time) string { | |||
| 103 | 258 | return string(buf[:n]) | |
| 104 | 259 | } | |
| 105 | 260 | ||
| 106 | - | // parseOSC11Reply extracts the (r, g, b) colour components from an | |
| 107 | - | // OSC 11 reply of the form "\x1b]11;rgb:RRRR/GGGG/BBBB\x07" (or with | |
| 108 | - | // ST terminator). The component widths can be 1, 2, 3, or 4 hex | |
| 109 | - | // digits per channel; we normalise them into the 0..1 range. | |
| 261 | + | // parseOSC11Reply extracts the (r, g, b) colour components from an OSC 11 | |
| 262 | + | // reply of the form "\\x1b]11;rgb:RRRR/GGGG/BBBB\\x07" (or with ST | |
| 263 | + | // terminator). The component widths can be 1, 2, 3, or 4 hex digits per | |
| 264 | + | // channel; we normalise them into the 0..1 range. | |
| 110 | 265 | func parseOSC11Reply(s string) (float64, float64, float64, bool) { | |
| 111 | - | // Locate "rgb:" within the response. | |
| 112 | 266 | i := strings.Index(s, "rgb:") | |
| 113 | 267 | if i < 0 { | |
| 114 | 268 | return 0, 0, 0, false | |
| 115 | 269 | } | |
| 116 | - | body := s[i+len("rgb:"):] | |
| 117 | - | // Trim trailing terminator(s). | |
| 118 | - | body = strings.TrimRight(body, "\x07") | |
| 270 | + | body := strings.TrimRight(s[i+len("rgb:"):], "\x07") | |
| 119 | 271 | body = strings.TrimSuffix(body, "\x1b\\") | |
| 120 | 272 | parts := strings.Split(body, "/") | |
| 121 | 273 | if len(parts) != 3 { | |
| @@ -129,9 +281,8 @@ func parseOSC11Reply(s string) (float64, float64, float64, bool) { | |||
| 129 | 281 | if err != nil { | |
| 130 | 282 | return 0, false | |
| 131 | 283 | } | |
| 132 | - | // Normalise to 0..1: max value is (16^len - 1). | |
| 133 | 284 | max := uint64(1) | |
| 134 | - | for j := 0; j < len(hexstr); j++ { | |
| 285 | + | for range hexstr { | |
| 135 | 286 | max *= 16 | |
| 136 | 287 | } | |
| 137 | 288 | max-- | |
| @@ -9,71 +9,229 @@ import ( | |||||
| 9 | "golang.org/x/term" | 9 | "golang.org/x/term" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | // DetectThemeFromBackground queries the controlling tty for its | 12 | + | // DetectTrueColor reports whether TERM or COLORTERM advertises direct color |
| 13 | - | // current background colour using the OSC 11 escape sequence and | 13 | + | // support. This follows the same conservative signals used by ../vev. |
| 14 | - | // returns Dark or Light based on the response's perceived | 14 | + | func DetectTrueColor(termEnv, colorTerm string) bool { |
| 15 | - | // luminance. Falls back to Dark when the terminal does not | 15 | + | switch strings.ToLower(strings.TrimSpace(colorTerm)) { |
| 16 | - | // reply within timeout, which is the expected behaviour for | 16 | + | case "truecolor", "24bit": |
| 17 | - | // terminals that do not implement OSC 11 (Linux console, VS Code's | 17 | + | return true |
| 18 | - | // integrated terminal in some configurations, tmux without | 18 | + | } |
| 19 | - | // pass-through, very old emulators). | 19 | + | |
| 20 | + | termEnv = strings.ToLower(strings.TrimSpace(termEnv)) | |||
| 21 | + | return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // DetectThemeFromBackground queries the controlling tty for its current | |||
| 25 | + | // foreground, background, and ANSI palette using OSC 10/11/4. The returned | |||
| 26 | + | // theme keeps the detected light/dark built-in colors for the normal auto | |||
| 27 | + | // theme and carries the terminal snapshot for the inherited theme. | |||
| 20 | // | 28 | // | ||
| 21 | - | // The query / parse runs synchronously before the TUI is | 29 | + | // The query / parse runs synchronously before the TUI is initialised so the |
| 22 | - | // initialised so the returned theme can drive the entire session. | 30 | + | // returned snapshot can drive the entire session. We briefly put stdin into |
| 23 | - | // We briefly put stdin into raw mode and disable echo so the OSC | 31 | + | // raw mode and disable echo so OSC replies do not leak onto the user's screen. |
| 24 | - | // reply doesn't leak onto the user's screen as visible bytes. | |||
| 25 | func DetectThemeFromBackground(timeout time.Duration) Theme { | 32 | func DetectThemeFromBackground(timeout time.Duration) Theme { | ||
| 26 | - | // Honour explicit override env var first; some users / CI envs | 33 | + | profile := detectTerminalProfile(timeout) |
| 27 | - | // know better than the heuristic. | 34 | + | base := Dark |
| 35 | + | ||||
| 36 | + | // Honour explicit override env vars first; some users and CI environments | |||
| 37 | + | // know better than the heuristic. The terminal snapshot is still retained | |||
| 38 | + | // so selecting the inherited theme later remains useful. | |||
| 28 | switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) { | 39 | switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) { | ||
| 29 | case "dark": | 40 | case "dark": | ||
| 30 | - | return Dark | 41 | + | base = Dark |
| 31 | case "light": | 42 | case "light": | ||
| 32 | - | return Light | 43 | + | base = Light |
| 44 | + | default: | |||
| 45 | + | if profile.HasBackground { | |||
| 46 | + | luma := 0.2126*float64(profile.Background.R) + | |||
| 47 | + | 0.7152*float64(profile.Background.G) + | |||
| 48 | + | 0.0722*float64(profile.Background.B) | |||
| 49 | + | if luma >= 127.5 { | |||
| 50 | + | base = Light | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 33 | } | 53 | } | ||
| 34 | 54 | ||||
| 35 | - | if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { | 55 | + | base.Terminal = profile |
| 36 | - | return Dark | 56 | + | return base |
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | type oscColorResponse struct { | |||
| 60 | + | kind int | |||
| 61 | + | slot int | |||
| 62 | + | color TerminalColor | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func detectTerminalProfile(timeout time.Duration) TerminalProfile { | |||
| 66 | + | profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))} | |||
| 67 | + | if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 { | |||
| 68 | + | return profile | |||
| 37 | } | 69 | } | ||
| 38 | 70 | ||||
| 39 | fd := int(os.Stdin.Fd()) | 71 | fd := int(os.Stdin.Fd()) | ||
| 40 | st, err := term.MakeRaw(fd) | 72 | st, err := term.MakeRaw(fd) | ||
| 41 | if err != nil { | 73 | if err != nil { | ||
| 42 | - | return Dark | 74 | + | return profile |
| 43 | } | 75 | } | ||
| 44 | defer term.Restore(fd, st) | 76 | defer term.Restore(fd, st) | ||
| 45 | 77 | ||||
| 46 | - | // Send the query. ST (\x1b\\) and BEL (\x07) are both accepted | 78 | + | queries := terminalColorQueries() |
| 47 | - | // terminators; some terminals only honour one of them, so we | 79 | + | if _, err := os.Stdout.Write([]byte(queries)); err != nil { |
| 48 | - | // send BEL which is more widely supported. | 80 | + | return profile |
| 49 | - | if _, err := os.Stdout.Write([]byte("\x1b]11;?\x07")); err != nil { | |||
| 50 | - | return Dark | |||
| 51 | } | 81 | } | ||
| 52 | 82 | ||||
| 53 | - | deadline := time.Now().Add(timeout) | 83 | + | responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount) |
| 54 | - | resp := readOSCResponse(deadline) | 84 | + | for _, response := range responses { |
| 55 | - | if resp == "" { | 85 | + | switch response.kind { |
| 56 | - | return Dark | 86 | + | case 10: |
| 87 | + | profile.Foreground = response.color | |||
| 88 | + | profile.HasForeground = true | |||
| 89 | + | case 11: | |||
| 90 | + | profile.Background = response.color | |||
| 91 | + | profile.HasBackground = true | |||
| 92 | + | case 4: | |||
| 93 | + | if response.slot >= 0 && response.slot < len(profile.Palette) { | |||
| 94 | + | profile.Palette[response.slot] = response.color | |||
| 95 | + | profile.PaletteKnown |= uint16(1) << response.slot | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 57 | } | 98 | } | ||
| 99 | + | if profile.HasBackground { | |||
| 100 | + | luma := 0.2126*float64(profile.Background.R) + | |||
| 101 | + | 0.7152*float64(profile.Background.G) + | |||
| 102 | + | 0.0722*float64(profile.Background.B) | |||
| 103 | + | profile.Light = luma >= 127.5 | |||
| 104 | + | profile.SchemeKnown = true | |||
| 105 | + | } | |||
| 106 | + | return profile | |||
| 107 | + | } | |||
| 58 | 108 | ||||
| 59 | - | r, g, b, ok := parseOSC11Reply(resp) | 109 | + | const ( |
| 60 | - | if !ok { | 110 | + | terminalColorQueryCount = 18 |
| 61 | - | return Dark | 111 | + | terminalColorResponseLimit = 64 |
| 112 | + | ) | |||
| 113 | + | ||||
| 114 | + | func terminalColorQueries() string { | |||
| 115 | + | var b strings.Builder | |||
| 116 | + | b.Grow(terminalColorQueryCount * 12) | |||
| 117 | + | b.WriteString("\x1b]10;?\x07\x1b]11;?\x07") | |||
| 118 | + | for slot := 0; slot < 16; slot++ { | |||
| 119 | + | b.WriteString("\x1b]4;") | |||
| 120 | + | b.WriteString(itoa(slot)) | |||
| 121 | + | b.WriteString(";?\x07") | |||
| 62 | } | 122 | } | ||
| 123 | + | return b.String() | |||
| 124 | + | } | |||
| 63 | 125 | ||||
| 64 | - | // Rec. 709 luma. Threshold at 0.5: anything brighter than | 126 | + | // readOSCColorResponses drains terminal replies until all expected replies |
| 65 | - | // mid-grey gets the light theme. | 127 | + | // arrive, the deadline expires, or stdin hits EOF. It only consumes complete |
| 66 | - | luma := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b) | 128 | + | // BEL/ST-terminated OSC color responses; an incomplete tail is discarded at |
| 67 | - | if luma >= 0.5 { | 129 | + | // timeout so a failed query cannot poison the next interactive read. |
| 68 | - | return Light | 130 | + | func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse { |
| 131 | + | responses := make([]oscColorResponse, 0, expected) | |||
| 132 | + | buf := make([]byte, 0, 64) | |||
| 133 | + | for len(responses) < expected && time.Now().Before(deadline) { | |||
| 134 | + | remaining := time.Until(deadline) | |||
| 135 | + | if remaining <= 0 { | |||
| 136 | + | break | |||
| 137 | + | } | |||
| 138 | + | b, ok, err := peekStdin(os.Stdin, remaining) | |||
| 139 | + | if err != nil || !ok { | |||
| 140 | + | break | |||
| 141 | + | } | |||
| 142 | + | if len(buf) >= terminalColorResponseLimit { | |||
| 143 | + | buf = buf[:0] | |||
| 144 | + | } | |||
| 145 | + | buf = append(buf, b) | |||
| 146 | + | if b != '\x07' && !(len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\') { | |||
| 147 | + | continue | |||
| 148 | + | } | |||
| 149 | + | if response, ok := parseOSCColorResponse(buf); ok { | |||
| 150 | + | responses = append(responses, response) | |||
| 151 | + | } | |||
| 152 | + | buf = buf[:0] | |||
| 153 | + | } | |||
| 154 | + | return responses | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) { | |||
| 158 | + | s := string(raw) | |||
| 159 | + | s = strings.TrimSuffix(s, "\x07") | |||
| 160 | + | s = strings.TrimSuffix(s, "\x1b\\") | |||
| 161 | + | ||||
| 162 | + | if strings.HasPrefix(s, "\x1b]10;") { | |||
| 163 | + | color, ok := parseTerminalRGB(s[len("\x1b]10;"):]) | |||
| 164 | + | return oscColorResponse{kind: 10, color: color}, ok | |||
| 165 | + | } | |||
| 166 | + | if strings.HasPrefix(s, "\x1b]11;") { | |||
| 167 | + | color, ok := parseTerminalRGB(s[len("\x1b]11;"):]) | |||
| 168 | + | return oscColorResponse{kind: 11, color: color}, ok | |||
| 169 | + | } | |||
| 170 | + | if strings.HasPrefix(s, "\x1b]4;") { | |||
| 171 | + | body := s[len("\x1b]4;"):] | |||
| 172 | + | parts := strings.SplitN(body, ";", 2) | |||
| 173 | + | if len(parts) != 2 || parts[0] == "" { | |||
| 174 | + | return oscColorResponse{}, false | |||
| 175 | + | } | |||
| 176 | + | slot, err := strconv.Atoi(parts[0]) | |||
| 177 | + | if err != nil || slot < 0 || slot >= 16 { | |||
| 178 | + | return oscColorResponse{}, false | |||
| 179 | + | } | |||
| 180 | + | color, ok := parseTerminalRGB(parts[1]) | |||
| 181 | + | return oscColorResponse{kind: 4, slot: slot, color: color}, ok | |||
| 182 | + | } | |||
| 183 | + | return oscColorResponse{}, false | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func parseTerminalRGB(value string) (TerminalColor, bool) { | |||
| 187 | + | value = strings.TrimSpace(value) | |||
| 188 | + | if strings.HasPrefix(value, "#") { | |||
| 189 | + | if len(value) != 7 { | |||
| 190 | + | return TerminalColor{}, false | |||
| 191 | + | } | |||
| 192 | + | r, ok := parseHexByte(value[1:3]) | |||
| 193 | + | if !ok { | |||
| 194 | + | return TerminalColor{}, false | |||
| 195 | + | } | |||
| 196 | + | g, ok := parseHexByte(value[3:5]) | |||
| 197 | + | if !ok { | |||
| 198 | + | return TerminalColor{}, false | |||
| 199 | + | } | |||
| 200 | + | b, ok := parseHexByte(value[5:7]) | |||
| 201 | + | if !ok { | |||
| 202 | + | return TerminalColor{}, false | |||
| 203 | + | } | |||
| 204 | + | return ColorRGB(r, g, b), true | |||
| 205 | + | } | |||
| 206 | + | if !strings.HasPrefix(value, "rgb:") { | |||
| 207 | + | return TerminalColor{}, false | |||
| 208 | + | } | |||
| 209 | + | parts := strings.Split(value[len("rgb:"):], "/") | |||
| 210 | + | if len(parts) != 3 { | |||
| 211 | + | return TerminalColor{}, false | |||
| 212 | + | } | |||
| 213 | + | var channels [3]int | |||
| 214 | + | for i, part := range parts { | |||
| 215 | + | if len(part) < 1 || len(part) > 4 { | |||
| 216 | + | return TerminalColor{}, false | |||
| 217 | + | } | |||
| 218 | + | v, err := strconv.ParseUint(part, 16, 16) | |||
| 219 | + | if err != nil { | |||
| 220 | + | return TerminalColor{}, false | |||
| 221 | + | } | |||
| 222 | + | max := uint64(1) | |||
| 223 | + | for range part { | |||
| 224 | + | max *= 16 | |||
| 225 | + | } | |||
| 226 | + | max-- | |||
| 227 | + | channels[i] = int(v * 255 / max) | |||
| 69 | } | 228 | } | ||
| 70 | - | return Dark | 229 | + | return ColorRGB(channels[0], channels[1], channels[2]), true |
| 71 | } | 230 | } | ||
| 72 | 231 | ||||
| 73 | - | // readOSCResponse drains stdin into a small buffer until either a | 232 | + | // readOSCResponse drains stdin into a small buffer until either a terminator |
| 74 | - | // terminator (BEL or ST) is seen, the deadline expires, or stdin | 233 | + | // (BEL or ST) is seen, the deadline expires, or stdin hits EOF. It remains a |
| 75 | - | // hits EOF. Returns whatever was collected, or "" on no usable | 234 | + | // small helper for callers that only need one OSC reply. |
| 76 | - | // response. | |||
| 77 | func readOSCResponse(deadline time.Time) string { | 235 | func readOSCResponse(deadline time.Time) string { | ||
| 78 | var buf [128]byte | 236 | var buf [128]byte | ||
| 79 | n := 0 | 237 | n := 0 | ||
| @@ -90,12 +248,9 @@ func readOSCResponse(deadline time.Time) string { | |||||
| 90 | buf[n] = b | 248 | buf[n] = b | ||
| 91 | n++ | 249 | n++ | ||
| 92 | } | 250 | } | ||
| 93 | - | // BEL terminator | |||
| 94 | if b == 0x07 { | 251 | if b == 0x07 { | ||
| 95 | return string(buf[:n]) | 252 | return string(buf[:n]) | ||
| 96 | } | 253 | } | ||
| 97 | - | // ST terminator: ESC then '\\'. We saw it the moment the | |||
| 98 | - | // previous byte was ESC and the current byte is '\\'. | |||
| 99 | if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' { | 254 | if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' { | ||
| 100 | return string(buf[:n]) | 255 | return string(buf[:n]) | ||
| 101 | } | 256 | } | ||
| @@ -103,19 +258,16 @@ func readOSCResponse(deadline time.Time) string { | |||||
| 103 | return string(buf[:n]) | 258 | return string(buf[:n]) | ||
| 104 | } | 259 | } | ||
| 105 | 260 | ||||
| 106 | - | // parseOSC11Reply extracts the (r, g, b) colour components from an | 261 | + | // parseOSC11Reply extracts the (r, g, b) colour components from an OSC 11 |
| 107 | - | // OSC 11 reply of the form "\x1b]11;rgb:RRRR/GGGG/BBBB\x07" (or with | 262 | + | // reply of the form "\\x1b]11;rgb:RRRR/GGGG/BBBB\\x07" (or with ST |
| 108 | - | // ST terminator). The component widths can be 1, 2, 3, or 4 hex | 263 | + | // terminator). The component widths can be 1, 2, 3, or 4 hex digits per |
| 109 | - | // digits per channel; we normalise them into the 0..1 range. | 264 | + | // channel; we normalise them into the 0..1 range. |
| 110 | func parseOSC11Reply(s string) (float64, float64, float64, bool) { | 265 | func parseOSC11Reply(s string) (float64, float64, float64, bool) { | ||
| 111 | - | // Locate "rgb:" within the response. | |||
| 112 | i := strings.Index(s, "rgb:") | 266 | i := strings.Index(s, "rgb:") | ||
| 113 | if i < 0 { | 267 | if i < 0 { | ||
| 114 | return 0, 0, 0, false | 268 | return 0, 0, 0, false | ||
| 115 | } | 269 | } | ||
| 116 | - | body := s[i+len("rgb:"):] | 270 | + | body := strings.TrimRight(s[i+len("rgb:"):], "\x07") |
| 117 | - | // Trim trailing terminator(s). | |||
| 118 | - | body = strings.TrimRight(body, "\x07") | |||
| 119 | body = strings.TrimSuffix(body, "\x1b\\") | 271 | body = strings.TrimSuffix(body, "\x1b\\") | ||
| 120 | parts := strings.Split(body, "/") | 272 | parts := strings.Split(body, "/") | ||
| 121 | if len(parts) != 3 { | 273 | if len(parts) != 3 { | ||
| @@ -129,9 +281,8 @@ func parseOSC11Reply(s string) (float64, float64, float64, bool) { | |||||
| 129 | if err != nil { | 281 | if err != nil { | ||
| 130 | return 0, false | 282 | return 0, false | ||
| 131 | } | 283 | } | ||
| 132 | - | // Normalise to 0..1: max value is (16^len - 1). | |||
| 133 | max := uint64(1) | 284 | max := uint64(1) | ||
| 134 | - | for j := 0; j < len(hexstr); j++ { | 285 | + | for range hexstr { |
| 135 | max *= 16 | 286 | max *= 16 | ||
| 136 | } | 287 | } | ||
| 137 | max-- | 288 | max-- | ||
M packages/tui/highlight.go +10 -1
| @@ -37,7 +37,11 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||
| 37 | 37 | return out | |
| 38 | 38 | } | |
| 39 | 39 | style := th.chromaStyle() | |
| 40 | - | formatter := formatters.Get("terminal256") | |
| 40 | + | formatterName := "terminal256" | |
| 41 | + | if th.ColorMode == ColorModeTrueColor { | |
| 42 | + | formatterName = "terminal16m" | |
| 43 | + | } | |
| 44 | + | formatter := formatters.Get(formatterName) | |
| 41 | 45 | if formatter == nil { | |
| 42 | 46 | return strings.Split(src, "\n") | |
| 43 | 47 | } | |
| @@ -238,7 +242,12 @@ func (th Theme) chromaStyle() *chroma.Style { | |||
| 238 | 242 | ||
| 239 | 243 | func (th Theme) syntaxKey() string { | |
| 240 | 244 | syn := th.Syntax | |
| 245 | + | outputMode := "256" | |
| 246 | + | if th.ColorMode == ColorModeTrueColor { | |
| 247 | + | outputMode = "truecolor" | |
| 248 | + | } | |
| 241 | 249 | return strings.Join([]string{ | |
| 250 | + | outputMode, | |
| 242 | 251 | th.SyntaxBaseStyle, | |
| 243 | 252 | syn.Keyword, | |
| 244 | 253 | syn.KeywordConstant, | |
| @@ -37,7 +37,11 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||||
| 37 | return out | 37 | return out | ||
| 38 | } | 38 | } | ||
| 39 | style := th.chromaStyle() | 39 | style := th.chromaStyle() | ||
| 40 | - | formatter := formatters.Get("terminal256") | 40 | + | formatterName := "terminal256" |
| 41 | + | if th.ColorMode == ColorModeTrueColor { | |||
| 42 | + | formatterName = "terminal16m" | |||
| 43 | + | } | |||
| 44 | + | formatter := formatters.Get(formatterName) | |||
| 41 | if formatter == nil { | 45 | if formatter == nil { | ||
| 42 | return strings.Split(src, "\n") | 46 | return strings.Split(src, "\n") | ||
| 43 | } | 47 | } | ||
| @@ -238,7 +242,12 @@ func (th Theme) chromaStyle() *chroma.Style { | |||||
| 238 | 242 | ||||
| 239 | func (th Theme) syntaxKey() string { | 243 | func (th Theme) syntaxKey() string { | ||
| 240 | syn := th.Syntax | 244 | syn := th.Syntax | ||
| 245 | + | outputMode := "256" | |||
| 246 | + | if th.ColorMode == ColorModeTrueColor { | |||
| 247 | + | outputMode = "truecolor" | |||
| 248 | + | } | |||
| 241 | return strings.Join([]string{ | 249 | return strings.Join([]string{ | ||
| 250 | + | outputMode, | |||
| 242 | th.SyntaxBaseStyle, | 251 | th.SyntaxBaseStyle, | ||
| 243 | syn.Keyword, | 252 | syn.Keyword, | ||
| 244 | syn.KeywordConstant, | 253 | syn.KeywordConstant, | ||
M packages/tui/layout.go +1 -1
| @@ -68,7 +68,7 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||
| 68 | 68 | if visible < bubbleW { | |
| 69 | 69 | content += strings.Repeat(" ", bubbleW-visible) | |
| 70 | 70 | } | |
| 71 | - | bg := sgrBGColor(th.UserBubbleBG) | |
| 71 | + | bg := th.sgrBGColor(th.UserBubbleBG) | |
| 72 | 72 | bar := bg + th.FG256(th.Accent, "▌ ") | |
| 73 | 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | |
| 74 | 74 | return bar + body | |
| @@ -68,7 +68,7 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||||
| 68 | if visible < bubbleW { | 68 | if visible < bubbleW { | ||
| 69 | content += strings.Repeat(" ", bubbleW-visible) | 69 | content += strings.Repeat(" ", bubbleW-visible) | ||
| 70 | } | 70 | } | ||
| 71 | - | bg := sgrBGColor(th.UserBubbleBG) | 71 | + | bg := th.sgrBGColor(th.UserBubbleBG) |
| 72 | bar := bg + th.FG256(th.Accent, "▌ ") | 72 | bar := bg + th.FG256(th.Accent, "▌ ") | ||
| 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | ||
| 74 | return bar + body | 74 | return bar + body | ||
M packages/tui/render.go +1 -1
| @@ -370,7 +370,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 370 | 370 | // overwrites, leaving ghost highlights behind. | |
| 371 | 371 | hasSelection := false | |
| 372 | 372 | if r.theme.Background == nil { | |
| 373 | - | selectionBG := sgrBG(r.theme.SelectionBG) | |
| 373 | + | selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG)) | |
| 374 | 374 | for _, l := range frame { | |
| 375 | 375 | if selectionBG != "" && strings.Contains(l, selectionBG) { | |
| 376 | 376 | hasSelection = true | |
| @@ -370,7 +370,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 370 | // overwrites, leaving ghost highlights behind. | 370 | // overwrites, leaving ghost highlights behind. | ||
| 371 | hasSelection := false | 371 | hasSelection := false | ||
| 372 | if r.theme.Background == nil { | 372 | if r.theme.Background == nil { | ||
| 373 | - | selectionBG := sgrBG(r.theme.SelectionBG) | 373 | + | selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG)) |
| 374 | for _, l := range frame { | 374 | for _, l := range frame { | ||
| 375 | if selectionBG != "" && strings.Contains(l, selectionBG) { | 375 | if selectionBG != "" && strings.Contains(l, selectionBG) { | ||
| 376 | hasSelection = true | 376 | hasSelection = true | ||
M packages/tui/theme.go +224 -18
| @@ -33,6 +33,39 @@ func ColorRGB(r, g, b int) TerminalColor { | |||
| 33 | 33 | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | + | // ColorMode is the output color depth used by an inherited theme. | |
| 37 | + | type ColorMode uint8 | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | ColorMode256 ColorMode = iota | |
| 41 | + | ColorModeTrueColor | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // TerminalProfile is the best-effort color snapshot reported by the | |
| 45 | + | // controlling terminal. The ANSI palette is optional; unknown slots are | |
| 46 | + | // resolved through the classic xterm-256 palette. | |
| 47 | + | type TerminalProfile struct { | |
| 48 | + | Foreground TerminalColor | |
| 49 | + | Background TerminalColor | |
| 50 | + | ||
| 51 | + | HasForeground bool | |
| 52 | + | HasBackground bool | |
| 53 | + | ||
| 54 | + | Palette [16]TerminalColor | |
| 55 | + | PaletteKnown uint16 | |
| 56 | + | TrueColor bool | |
| 57 | + | Light bool | |
| 58 | + | SchemeKnown bool | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // PaletteColor returns a reported ANSI palette entry when available. | |
| 62 | + | func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) { | |
| 63 | + | if index < 0 || index >= len(p.Palette) || p.PaletteKnown&(uint16(1)<<index) == 0 { | |
| 64 | + | return TerminalColor{}, false | |
| 65 | + | } | |
| 66 | + | return p.Palette[index], true | |
| 67 | + | } | |
| 68 | + | ||
| 36 | 69 | // ANSI 256-color palette used by zot. Defined as numeric codes so we | |
| 37 | 70 | // can swap themes without changing any render code. | |
| 38 | 71 | type Theme struct { | |
| @@ -53,6 +86,13 @@ type Theme struct { | |||
| 53 | 86 | SelectionBG int // background for highlighted rows | |
| 54 | 87 | SelectionFG int // foreground for highlighted rows | |
| 55 | 88 | ||
| 89 | + | // Inherited selects terminal-owned colors and uses ColorMode to choose | |
| 90 | + | // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep | |
| 91 | + | // the historical indexed output because their values are already fixed. | |
| 92 | + | Inherited bool | |
| 93 | + | ColorMode ColorMode | |
| 94 | + | Terminal TerminalProfile | |
| 95 | + | ||
| 56 | 96 | SpinnerFrames []string | |
| 57 | 97 | SpinnerMessages []string | |
| 58 | 98 | SpinnerIntervalMS int | |
| @@ -193,24 +233,105 @@ var Light = Theme{ | |||
| 193 | 233 | Syntax: nordSyntax, | |
| 194 | 234 | } | |
| 195 | 235 | ||
| 196 | - | // FG256 wraps s in foreground color c using ANSI 256-color SGR. | |
| 236 | + | // FG256 wraps s in a foreground color. Explicit themes retain the | |
| 237 | + | // historical ANSI 256-color sequence; inherited themes resolve terminal | |
| 238 | + | // palette entries and emit truecolor when the terminal advertises it. | |
| 197 | 239 | func (t Theme) FG256(c int, s string) string { | |
| 198 | - | return sgrFG(c) + s + reset | |
| 240 | + | return t.fgPrefix(c) + s + reset | |
| 199 | 241 | } | |
| 200 | 242 | ||
| 201 | - | // BG256 wraps s in background color c using ANSI 256-color SGR. | |
| 202 | - | // Useful when the visible cell needs a coloured fill but the | |
| 203 | - | // underlying character should be a regular space (so mouse-copy | |
| 204 | - | // from the terminal yields whitespace instead of a glyph). | |
| 243 | + | // BG256 wraps s in a background color using the theme's output capability. | |
| 244 | + | // Useful when the visible cell needs a coloured fill but the underlying | |
| 245 | + | // character should be a regular space (so mouse-copy yields whitespace). | |
| 205 | 246 | func (t Theme) BG256(c int, s string) string { | |
| 206 | - | return sgrBG(c) + s + reset | |
| 247 | + | return t.bgPrefix(Color256(c)) + s + reset | |
| 207 | 248 | } | |
| 208 | 249 | ||
| 209 | - | // BG wraps s in a terminal background colour. Unlike BG256, this can | |
| 210 | - | // target ANSI theme slots or truecolor RGB, which lets selected UI | |
| 211 | - | // surfaces follow the user's terminal theme. | |
| 250 | + | // BG wraps s in a terminal background color. Inherited themes quantize RGB | |
| 251 | + | // values on terminals without truecolor support. | |
| 212 | 252 | func (t Theme) BG(c TerminalColor, s string) string { | |
| 213 | - | return sgrBGColor(c) + s + reset | |
| 253 | + | return t.bgPrefix(c) + s + reset | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (t Theme) fgPrefix(index int) string { | |
| 257 | + | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | |
| 258 | + | return sgrFGColor(t.resolveColor(t.Terminal.Foreground)) | |
| 259 | + | } | |
| 260 | + | return sgrFGColor(t.resolveColor(Color256(index))) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (t Theme) bgPrefix(color TerminalColor) string { | |
| 264 | + | return sgrBGColor(t.resolveColor(color)) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // DimColor fades an indexed color toward the terminal background. It is used | |
| 268 | + | // by inherited theme construction for muted surfaces and is also available to | |
| 269 | + | // callers that need terminal-aware dimming for overlays. | |
| 270 | + | func (t Theme) DimColor(index, percent int) TerminalColor { | |
| 271 | + | percent = clampPercent(percent) | |
| 272 | + | foreground := t.resolveForeground(index) | |
| 273 | + | background := t.Terminal.Background | |
| 274 | + | if !t.Terminal.HasBackground { | |
| 275 | + | background = Color256(0) | |
| 276 | + | } | |
| 277 | + | fg, _ := rgbForTerminalColor(foreground) | |
| 278 | + | bg, _ := rgbForTerminalColor(background) | |
| 279 | + | return t.resolveColor(ColorRGB( | |
| 280 | + | blendChannel(fg[0], bg[0], percent), | |
| 281 | + | blendChannel(fg[1], bg[1], percent), | |
| 282 | + | blendChannel(fg[2], bg[2], percent), | |
| 283 | + | )) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (t Theme) resolveForeground(index int) TerminalColor { | |
| 287 | + | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | |
| 288 | + | return t.resolveColor(t.Terminal.Foreground) | |
| 289 | + | } | |
| 290 | + | return t.resolveColor(Color256(index)) | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func (t Theme) resolveColor(color TerminalColor) TerminalColor { | |
| 294 | + | switch color.Mode { | |
| 295 | + | case terminalColor256: | |
| 296 | + | index := clampXtermIndex(color.Index) | |
| 297 | + | if t.Inherited { | |
| 298 | + | if palette, ok := t.Terminal.PaletteColor(index); ok { | |
| 299 | + | if t.ColorMode == ColorModeTrueColor { | |
| 300 | + | return palette | |
| 301 | + | } | |
| 302 | + | return Color256(index) | |
| 303 | + | } | |
| 304 | + | if t.ColorMode == ColorModeTrueColor { | |
| 305 | + | r, g, b := xterm256RGB(index) | |
| 306 | + | return ColorRGB(r, g, b) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | if t.ColorMode == ColorModeTrueColor { | |
| 310 | + | r, g, b := xterm256RGB(index) | |
| 311 | + | return ColorRGB(r, g, b) | |
| 312 | + | } | |
| 313 | + | return Color256(index) | |
| 314 | + | case terminalColorRGB: | |
| 315 | + | if t.Inherited && t.ColorMode == ColorMode256 { | |
| 316 | + | return Color256(nearestXtermColor(color.R, color.G, color.B)) | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | return color | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func sgrFGColor(color TerminalColor) string { | |
| 323 | + | switch color.Mode { | |
| 324 | + | case terminalColorANSI: | |
| 325 | + | return "\x1b[" + itoa(color.Index) + "m" | |
| 326 | + | case terminalColorRGB: | |
| 327 | + | return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m" | |
| 328 | + | default: | |
| 329 | + | return sgrFG(clampXtermIndex(color.Index)) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (t Theme) sgrBGColor(color TerminalColor) string { | |
| 334 | + | return sgrBGColor(t.resolveColor(color)) | |
| 214 | 335 | } | |
| 215 | 336 | ||
| 216 | 337 | // AccentBar returns a 2-cell-wide leader: a coloured half-block | |
| @@ -230,7 +351,7 @@ func (t Theme) Highlight(s string) string { | |||
| 230 | 351 | ||
| 231 | 352 | // SelectionStyle returns the SGR prefix for the theme's selected row. | |
| 232 | 353 | func (t Theme) SelectionStyle() string { | |
| 233 | - | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) | |
| 354 | + | return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG)) | |
| 234 | 355 | } | |
| 235 | 356 | ||
| 236 | 357 | // BackgroundStyle returns the SGR prefix for the optional full-row | |
| @@ -240,14 +361,14 @@ func (t Theme) BackgroundStyle() string { | |||
| 240 | 361 | if t.Background == nil { | |
| 241 | 362 | return "" | |
| 242 | 363 | } | |
| 243 | - | return sgrBGColor(*t.Background) | |
| 364 | + | return t.bgPrefix(*t.Background) | |
| 244 | 365 | } | |
| 245 | 366 | ||
| 246 | 367 | // SelectionStyleFG returns the SGR prefix for selected-row text with | |
| 247 | 368 | // a custom foreground. Useful for preserving semantic marks inside a | |
| 248 | 369 | // highlighted row without hardcoding escape sequences outside theme.go. | |
| 249 | 370 | func (t Theme) SelectionStyleFG(fg int) string { | |
| 250 | - | return sgrFG(fg) + sgrBG(t.SelectionBG) | |
| 371 | + | return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG)) | |
| 251 | 372 | } | |
| 252 | 373 | ||
| 253 | 374 | // PadHighlight styles s and extends the selection background to the | |
| @@ -270,7 +391,7 @@ func (t Theme) UserBubble(s string, width int) string { | |||
| 270 | 391 | if visible < width { | |
| 271 | 392 | s += strings.Repeat(" ", width-visible) | |
| 272 | 393 | } | |
| 273 | - | return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset | |
| 394 | + | return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset | |
| 274 | 395 | } | |
| 275 | 396 | ||
| 276 | 397 | // UserBubbleRow renders one user-bubble row prefixed with a coloured | |
| @@ -302,19 +423,104 @@ func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" } | |||
| 302 | 423 | ||
| 303 | 424 | const reset = "\x1b[0m" | |
| 304 | 425 | ||
| 305 | - | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" } | |
| 306 | - | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" } | |
| 426 | + | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" } | |
| 427 | + | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" } | |
| 307 | 428 | func sgrBGColor(c TerminalColor) string { | |
| 308 | 429 | switch c.Mode { | |
| 309 | 430 | case terminalColorANSI: | |
| 310 | 431 | return "\x1b[" + itoa(c.Index) + "m" | |
| 311 | 432 | case terminalColorRGB: | |
| 312 | - | return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m" | |
| 433 | + | return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m" | |
| 313 | 434 | default: | |
| 314 | 435 | return sgrBG(c.Index) | |
| 315 | 436 | } | |
| 316 | 437 | } | |
| 317 | 438 | ||
| 439 | + | func clampXtermIndex(index int) int { | |
| 440 | + | if index < 0 { | |
| 441 | + | return 0 | |
| 442 | + | } | |
| 443 | + | if index > 255 { | |
| 444 | + | return 255 | |
| 445 | + | } | |
| 446 | + | return index | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func clampByte(value int) int { | |
| 450 | + | if value < 0 { | |
| 451 | + | return 0 | |
| 452 | + | } | |
| 453 | + | if value > 255 { | |
| 454 | + | return 255 | |
| 455 | + | } | |
| 456 | + | return value | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | func clampPercent(percent int) int { | |
| 460 | + | if percent < 0 { | |
| 461 | + | return 0 | |
| 462 | + | } | |
| 463 | + | if percent > 100 { | |
| 464 | + | return 100 | |
| 465 | + | } | |
| 466 | + | return percent | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func rgbForTerminalColor(color TerminalColor) ([3]int, bool) { | |
| 470 | + | switch color.Mode { | |
| 471 | + | case terminalColorRGB: | |
| 472 | + | return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true | |
| 473 | + | case terminalColor256: | |
| 474 | + | r, g, b := xterm256RGB(clampXtermIndex(color.Index)) | |
| 475 | + | return [3]int{r, g, b}, true | |
| 476 | + | case terminalColorANSI: | |
| 477 | + | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |
| 478 | + | r, g, b := xterm256RGB(index) | |
| 479 | + | return [3]int{r, g, b}, true | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | return [3]int{}, false | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func ansiSGRToXtermIndex(sgr int) (int, bool) { | |
| 486 | + | switch { | |
| 487 | + | case sgr >= 30 && sgr <= 37: | |
| 488 | + | return sgr - 30, true | |
| 489 | + | case sgr >= 90 && sgr <= 97: | |
| 490 | + | return sgr - 90 + 8, true | |
| 491 | + | case sgr >= 40 && sgr <= 47: | |
| 492 | + | return sgr - 40, true | |
| 493 | + | case sgr >= 100 && sgr <= 107: | |
| 494 | + | return sgr - 100 + 8, true | |
| 495 | + | default: | |
| 496 | + | return 0, false | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func blendChannel(from, to, percent int) int { | |
| 501 | + | return clampByte((from*(100-percent) + to*percent + 50) / 100) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func nearestXtermColor(r, g, b int) int { | |
| 505 | + | r = clampByte(r) | |
| 506 | + | g = clampByte(g) | |
| 507 | + | b = clampByte(b) | |
| 508 | + | best := 0 | |
| 509 | + | bestDistance := int(^uint(0) >> 1) | |
| 510 | + | for index := 0; index < 256; index++ { | |
| 511 | + | cr, cg, cb := xterm256RGB(index) | |
| 512 | + | dr := r - cr | |
| 513 | + | dg := g - cg | |
| 514 | + | db := b - cb | |
| 515 | + | distance := dr*dr + dg*dg + db*db | |
| 516 | + | if distance < bestDistance { | |
| 517 | + | best = index | |
| 518 | + | bestDistance = distance | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | return best | |
| 522 | + | } | |
| 523 | + | ||
| 318 | 524 | // small itoa to avoid pulling strconv into this hot path twice. | |
| 319 | 525 | func itoa(n int) string { | |
| 320 | 526 | if n == 0 { | |
| @@ -33,6 +33,39 @@ func ColorRGB(r, g, b int) TerminalColor { | |||||
| 33 | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | 33 | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | + | // ColorMode is the output color depth used by an inherited theme. | |||
| 37 | + | type ColorMode uint8 | |||
| 38 | + | ||||
| 39 | + | const ( | |||
| 40 | + | ColorMode256 ColorMode = iota | |||
| 41 | + | ColorModeTrueColor | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | // TerminalProfile is the best-effort color snapshot reported by the | |||
| 45 | + | // controlling terminal. The ANSI palette is optional; unknown slots are | |||
| 46 | + | // resolved through the classic xterm-256 palette. | |||
| 47 | + | type TerminalProfile struct { | |||
| 48 | + | Foreground TerminalColor | |||
| 49 | + | Background TerminalColor | |||
| 50 | + | ||||
| 51 | + | HasForeground bool | |||
| 52 | + | HasBackground bool | |||
| 53 | + | ||||
| 54 | + | Palette [16]TerminalColor | |||
| 55 | + | PaletteKnown uint16 | |||
| 56 | + | TrueColor bool | |||
| 57 | + | Light bool | |||
| 58 | + | SchemeKnown bool | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // PaletteColor returns a reported ANSI palette entry when available. | |||
| 62 | + | func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) { | |||
| 63 | + | if index < 0 || index >= len(p.Palette) || p.PaletteKnown&(uint16(1)<<index) == 0 { | |||
| 64 | + | return TerminalColor{}, false | |||
| 65 | + | } | |||
| 66 | + | return p.Palette[index], true | |||
| 67 | + | } | |||
| 68 | + | ||||
| 36 | // ANSI 256-color palette used by zot. Defined as numeric codes so we | 69 | // ANSI 256-color palette used by zot. Defined as numeric codes so we | ||
| 37 | // can swap themes without changing any render code. | 70 | // can swap themes without changing any render code. | ||
| 38 | type Theme struct { | 71 | type Theme struct { | ||
| @@ -53,6 +86,13 @@ type Theme struct { | |||||
| 53 | SelectionBG int // background for highlighted rows | 86 | SelectionBG int // background for highlighted rows | ||
| 54 | SelectionFG int // foreground for highlighted rows | 87 | SelectionFG int // foreground for highlighted rows | ||
| 55 | 88 | ||||
| 89 | + | // Inherited selects terminal-owned colors and uses ColorMode to choose | |||
| 90 | + | // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep | |||
| 91 | + | // the historical indexed output because their values are already fixed. | |||
| 92 | + | Inherited bool | |||
| 93 | + | ColorMode ColorMode | |||
| 94 | + | Terminal TerminalProfile | |||
| 95 | + | ||||
| 56 | SpinnerFrames []string | 96 | SpinnerFrames []string | ||
| 57 | SpinnerMessages []string | 97 | SpinnerMessages []string | ||
| 58 | SpinnerIntervalMS int | 98 | SpinnerIntervalMS int | ||
| @@ -193,24 +233,105 @@ var Light = Theme{ | |||||
| 193 | Syntax: nordSyntax, | 233 | Syntax: nordSyntax, | ||
| 194 | } | 234 | } | ||
| 195 | 235 | ||||
| 196 | - | // FG256 wraps s in foreground color c using ANSI 256-color SGR. | 236 | + | // FG256 wraps s in a foreground color. Explicit themes retain the |
| 237 | + | // historical ANSI 256-color sequence; inherited themes resolve terminal | |||
| 238 | + | // palette entries and emit truecolor when the terminal advertises it. | |||
| 197 | func (t Theme) FG256(c int, s string) string { | 239 | func (t Theme) FG256(c int, s string) string { | ||
| 198 | - | return sgrFG(c) + s + reset | 240 | + | return t.fgPrefix(c) + s + reset |
| 199 | } | 241 | } | ||
| 200 | 242 | ||||
| 201 | - | // BG256 wraps s in background color c using ANSI 256-color SGR. | 243 | + | // BG256 wraps s in a background color using the theme's output capability. |
| 202 | - | // Useful when the visible cell needs a coloured fill but the | 244 | + | // Useful when the visible cell needs a coloured fill but the underlying |
| 203 | - | // underlying character should be a regular space (so mouse-copy | 245 | + | // character should be a regular space (so mouse-copy yields whitespace). |
| 204 | - | // from the terminal yields whitespace instead of a glyph). | |||
| 205 | func (t Theme) BG256(c int, s string) string { | 246 | func (t Theme) BG256(c int, s string) string { | ||
| 206 | - | return sgrBG(c) + s + reset | 247 | + | return t.bgPrefix(Color256(c)) + s + reset |
| 207 | } | 248 | } | ||
| 208 | 249 | ||||
| 209 | - | // BG wraps s in a terminal background colour. Unlike BG256, this can | 250 | + | // BG wraps s in a terminal background color. Inherited themes quantize RGB |
| 210 | - | // target ANSI theme slots or truecolor RGB, which lets selected UI | 251 | + | // values on terminals without truecolor support. |
| 211 | - | // surfaces follow the user's terminal theme. | |||
| 212 | func (t Theme) BG(c TerminalColor, s string) string { | 252 | func (t Theme) BG(c TerminalColor, s string) string { | ||
| 213 | - | return sgrBGColor(c) + s + reset | 253 | + | return t.bgPrefix(c) + s + reset |
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func (t Theme) fgPrefix(index int) string { | |||
| 257 | + | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | |||
| 258 | + | return sgrFGColor(t.resolveColor(t.Terminal.Foreground)) | |||
| 259 | + | } | |||
| 260 | + | return sgrFGColor(t.resolveColor(Color256(index))) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func (t Theme) bgPrefix(color TerminalColor) string { | |||
| 264 | + | return sgrBGColor(t.resolveColor(color)) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // DimColor fades an indexed color toward the terminal background. It is used | |||
| 268 | + | // by inherited theme construction for muted surfaces and is also available to | |||
| 269 | + | // callers that need terminal-aware dimming for overlays. | |||
| 270 | + | func (t Theme) DimColor(index, percent int) TerminalColor { | |||
| 271 | + | percent = clampPercent(percent) | |||
| 272 | + | foreground := t.resolveForeground(index) | |||
| 273 | + | background := t.Terminal.Background | |||
| 274 | + | if !t.Terminal.HasBackground { | |||
| 275 | + | background = Color256(0) | |||
| 276 | + | } | |||
| 277 | + | fg, _ := rgbForTerminalColor(foreground) | |||
| 278 | + | bg, _ := rgbForTerminalColor(background) | |||
| 279 | + | return t.resolveColor(ColorRGB( | |||
| 280 | + | blendChannel(fg[0], bg[0], percent), | |||
| 281 | + | blendChannel(fg[1], bg[1], percent), | |||
| 282 | + | blendChannel(fg[2], bg[2], percent), | |||
| 283 | + | )) | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func (t Theme) resolveForeground(index int) TerminalColor { | |||
| 287 | + | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | |||
| 288 | + | return t.resolveColor(t.Terminal.Foreground) | |||
| 289 | + | } | |||
| 290 | + | return t.resolveColor(Color256(index)) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func (t Theme) resolveColor(color TerminalColor) TerminalColor { | |||
| 294 | + | switch color.Mode { | |||
| 295 | + | case terminalColor256: | |||
| 296 | + | index := clampXtermIndex(color.Index) | |||
| 297 | + | if t.Inherited { | |||
| 298 | + | if palette, ok := t.Terminal.PaletteColor(index); ok { | |||
| 299 | + | if t.ColorMode == ColorModeTrueColor { | |||
| 300 | + | return palette | |||
| 301 | + | } | |||
| 302 | + | return Color256(index) | |||
| 303 | + | } | |||
| 304 | + | if t.ColorMode == ColorModeTrueColor { | |||
| 305 | + | r, g, b := xterm256RGB(index) | |||
| 306 | + | return ColorRGB(r, g, b) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | if t.ColorMode == ColorModeTrueColor { | |||
| 310 | + | r, g, b := xterm256RGB(index) | |||
| 311 | + | return ColorRGB(r, g, b) | |||
| 312 | + | } | |||
| 313 | + | return Color256(index) | |||
| 314 | + | case terminalColorRGB: | |||
| 315 | + | if t.Inherited && t.ColorMode == ColorMode256 { | |||
| 316 | + | return Color256(nearestXtermColor(color.R, color.G, color.B)) | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | return color | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | func sgrFGColor(color TerminalColor) string { | |||
| 323 | + | switch color.Mode { | |||
| 324 | + | case terminalColorANSI: | |||
| 325 | + | return "\x1b[" + itoa(color.Index) + "m" | |||
| 326 | + | case terminalColorRGB: | |||
| 327 | + | return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m" | |||
| 328 | + | default: | |||
| 329 | + | return sgrFG(clampXtermIndex(color.Index)) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (t Theme) sgrBGColor(color TerminalColor) string { | |||
| 334 | + | return sgrBGColor(t.resolveColor(color)) | |||
| 214 | } | 335 | } | ||
| 215 | 336 | ||||
| 216 | // AccentBar returns a 2-cell-wide leader: a coloured half-block | 337 | // AccentBar returns a 2-cell-wide leader: a coloured half-block | ||
| @@ -230,7 +351,7 @@ func (t Theme) Highlight(s string) string { | |||||
| 230 | 351 | ||||
| 231 | // SelectionStyle returns the SGR prefix for the theme's selected row. | 352 | // SelectionStyle returns the SGR prefix for the theme's selected row. | ||
| 232 | func (t Theme) SelectionStyle() string { | 353 | func (t Theme) SelectionStyle() string { | ||
| 233 | - | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) | 354 | + | return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG)) |
| 234 | } | 355 | } | ||
| 235 | 356 | ||||
| 236 | // BackgroundStyle returns the SGR prefix for the optional full-row | 357 | // BackgroundStyle returns the SGR prefix for the optional full-row | ||
| @@ -240,14 +361,14 @@ func (t Theme) BackgroundStyle() string { | |||||
| 240 | if t.Background == nil { | 361 | if t.Background == nil { | ||
| 241 | return "" | 362 | return "" | ||
| 242 | } | 363 | } | ||
| 243 | - | return sgrBGColor(*t.Background) | 364 | + | return t.bgPrefix(*t.Background) |
| 244 | } | 365 | } | ||
| 245 | 366 | ||||
| 246 | // SelectionStyleFG returns the SGR prefix for selected-row text with | 367 | // SelectionStyleFG returns the SGR prefix for selected-row text with | ||
| 247 | // a custom foreground. Useful for preserving semantic marks inside a | 368 | // a custom foreground. Useful for preserving semantic marks inside a | ||
| 248 | // highlighted row without hardcoding escape sequences outside theme.go. | 369 | // highlighted row without hardcoding escape sequences outside theme.go. | ||
| 249 | func (t Theme) SelectionStyleFG(fg int) string { | 370 | func (t Theme) SelectionStyleFG(fg int) string { | ||
| 250 | - | return sgrFG(fg) + sgrBG(t.SelectionBG) | 371 | + | return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG)) |
| 251 | } | 372 | } | ||
| 252 | 373 | ||||
| 253 | // PadHighlight styles s and extends the selection background to the | 374 | // PadHighlight styles s and extends the selection background to the | ||
| @@ -270,7 +391,7 @@ func (t Theme) UserBubble(s string, width int) string { | |||||
| 270 | if visible < width { | 391 | if visible < width { | ||
| 271 | s += strings.Repeat(" ", width-visible) | 392 | s += strings.Repeat(" ", width-visible) | ||
| 272 | } | 393 | } | ||
| 273 | - | return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset | 394 | + | return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset |
| 274 | } | 395 | } | ||
| 275 | 396 | ||||
| 276 | // UserBubbleRow renders one user-bubble row prefixed with a coloured | 397 | // UserBubbleRow renders one user-bubble row prefixed with a coloured | ||
| @@ -302,19 +423,104 @@ func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" } | |||||
| 302 | 423 | ||||
| 303 | const reset = "\x1b[0m" | 424 | const reset = "\x1b[0m" | ||
| 304 | 425 | ||||
| 305 | - | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" } | 426 | + | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" } |
| 306 | - | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" } | 427 | + | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" } |
| 307 | func sgrBGColor(c TerminalColor) string { | 428 | func sgrBGColor(c TerminalColor) string { | ||
| 308 | switch c.Mode { | 429 | switch c.Mode { | ||
| 309 | case terminalColorANSI: | 430 | case terminalColorANSI: | ||
| 310 | return "\x1b[" + itoa(c.Index) + "m" | 431 | return "\x1b[" + itoa(c.Index) + "m" | ||
| 311 | case terminalColorRGB: | 432 | case terminalColorRGB: | ||
| 312 | - | return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m" | 433 | + | return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m" |
| 313 | default: | 434 | default: | ||
| 314 | return sgrBG(c.Index) | 435 | return sgrBG(c.Index) | ||
| 315 | } | 436 | } | ||
| 316 | } | 437 | } | ||
| 317 | 438 | ||||
| 439 | + | func clampXtermIndex(index int) int { | |||
| 440 | + | if index < 0 { | |||
| 441 | + | return 0 | |||
| 442 | + | } | |||
| 443 | + | if index > 255 { | |||
| 444 | + | return 255 | |||
| 445 | + | } | |||
| 446 | + | return index | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func clampByte(value int) int { | |||
| 450 | + | if value < 0 { | |||
| 451 | + | return 0 | |||
| 452 | + | } | |||
| 453 | + | if value > 255 { | |||
| 454 | + | return 255 | |||
| 455 | + | } | |||
| 456 | + | return value | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func clampPercent(percent int) int { | |||
| 460 | + | if percent < 0 { | |||
| 461 | + | return 0 | |||
| 462 | + | } | |||
| 463 | + | if percent > 100 { | |||
| 464 | + | return 100 | |||
| 465 | + | } | |||
| 466 | + | return percent | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | func rgbForTerminalColor(color TerminalColor) ([3]int, bool) { | |||
| 470 | + | switch color.Mode { | |||
| 471 | + | case terminalColorRGB: | |||
| 472 | + | return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true | |||
| 473 | + | case terminalColor256: | |||
| 474 | + | r, g, b := xterm256RGB(clampXtermIndex(color.Index)) | |||
| 475 | + | return [3]int{r, g, b}, true | |||
| 476 | + | case terminalColorANSI: | |||
| 477 | + | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |||
| 478 | + | r, g, b := xterm256RGB(index) | |||
| 479 | + | return [3]int{r, g, b}, true | |||
| 480 | + | } | |||
| 481 | + | } | |||
| 482 | + | return [3]int{}, false | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | func ansiSGRToXtermIndex(sgr int) (int, bool) { | |||
| 486 | + | switch { | |||
| 487 | + | case sgr >= 30 && sgr <= 37: | |||
| 488 | + | return sgr - 30, true | |||
| 489 | + | case sgr >= 90 && sgr <= 97: | |||
| 490 | + | return sgr - 90 + 8, true | |||
| 491 | + | case sgr >= 40 && sgr <= 47: | |||
| 492 | + | return sgr - 40, true | |||
| 493 | + | case sgr >= 100 && sgr <= 107: | |||
| 494 | + | return sgr - 100 + 8, true | |||
| 495 | + | default: | |||
| 496 | + | return 0, false | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func blendChannel(from, to, percent int) int { | |||
| 501 | + | return clampByte((from*(100-percent) + to*percent + 50) / 100) | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func nearestXtermColor(r, g, b int) int { | |||
| 505 | + | r = clampByte(r) | |||
| 506 | + | g = clampByte(g) | |||
| 507 | + | b = clampByte(b) | |||
| 508 | + | best := 0 | |||
| 509 | + | bestDistance := int(^uint(0) >> 1) | |||
| 510 | + | for index := 0; index < 256; index++ { | |||
| 511 | + | cr, cg, cb := xterm256RGB(index) | |||
| 512 | + | dr := r - cr | |||
| 513 | + | dg := g - cg | |||
| 514 | + | db := b - cb | |||
| 515 | + | distance := dr*dr + dg*dg + db*db | |||
| 516 | + | if distance < bestDistance { | |||
| 517 | + | best = index | |||
| 518 | + | bestDistance = distance | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | return best | |||
| 522 | + | } | |||
| 523 | + | ||||
| 318 | // small itoa to avoid pulling strconv into this hot path twice. | 524 | // small itoa to avoid pulling strconv into this hot path twice. | ||
| 319 | func itoa(n int) string { | 525 | func itoa(n int) string { | ||
| 320 | if n == 0 { | 526 | if n == 0 { | ||
A packages/tui/theme_inherited_test.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestDetectTrueColor(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | term string | |
| 13 | + | colorTerm string | |
| 14 | + | want bool | |
| 15 | + | }{ | |
| 16 | + | {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true}, | |
| 17 | + | {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true}, | |
| 18 | + | {name: "direct TERM", term: "xterm-direct", want: true}, | |
| 19 | + | {name: "direct TERM suffix", term: "screen-direct", want: true}, | |
| 20 | + | {name: "256 color TERM", term: "xterm-256color", colorTerm: "", want: false}, | |
| 21 | + | {name: "unknown TERM", term: "dumb", colorTerm: "", want: false}, | |
| 22 | + | } | |
| 23 | + | for _, tt := range tests { | |
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |
| 25 | + | if got := DetectTrueColor(tt.term, tt.colorTerm); got != tt.want { | |
| 26 | + | t.Fatalf("DetectTrueColor(%q, %q) = %v, want %v", tt.term, tt.colorTerm, got, tt.want) | |
| 27 | + | } | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestParseOSCColorResponse(t *testing.T) { | |
| 33 | + | tests := []struct { | |
| 34 | + | name string | |
| 35 | + | raw string | |
| 36 | + | kind int | |
| 37 | + | slot int | |
| 38 | + | want TerminalColor | |
| 39 | + | }{ | |
| 40 | + | { | |
| 41 | + | name: "foreground hash", | |
| 42 | + | raw: "\x1b]10;#123456\x07", | |
| 43 | + | kind: 10, | |
| 44 | + | want: ColorRGB(0x12, 0x34, 0x56), | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "background rgb", | |
| 48 | + | raw: "\x1b]11;rgb:ffff/8000/0000\x1b\\", | |
| 49 | + | kind: 11, | |
| 50 | + | want: ColorRGB(255, 127, 0), | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | name: "palette slot", | |
| 54 | + | raw: "\x1b]4;12;rgb:12/34/56\x07", | |
| 55 | + | kind: 4, | |
| 56 | + | slot: 12, | |
| 57 | + | want: ColorRGB(0x12, 0x34, 0x56), | |
| 58 | + | }, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | got, ok := parseOSCColorResponse([]byte(tt.raw)) | |
| 63 | + | if !ok { | |
| 64 | + | t.Fatal("parseOSCColorResponse returned !ok") | |
| 65 | + | } | |
| 66 | + | if got.kind != tt.kind || got.slot != tt.slot || got.color != tt.want { | |
| 67 | + | t.Fatalf("response = %#v, want kind=%d slot=%d color=%#v", got, tt.kind, tt.slot, tt.want) | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func inheritedTestTheme(trueColor bool) Theme { | |
| 74 | + | detected := Dark | |
| 75 | + | detected.Terminal = TerminalProfile{ | |
| 76 | + | Foreground: ColorRGB(220, 210, 200), | |
| 77 | + | Background: ColorRGB(12, 18, 24), | |
| 78 | + | HasForeground: true, | |
| 79 | + | HasBackground: true, | |
| 80 | + | TrueColor: trueColor, | |
| 81 | + | } | |
| 82 | + | detected.Terminal.Palette[10] = ColorRGB(20, 220, 80) | |
| 83 | + | detected.Terminal.Palette[12] = ColorRGB(40, 100, 240) | |
| 84 | + | detected.Terminal.Palette[13] = ColorRGB(220, 70, 210) | |
| 85 | + | detected.Terminal.PaletteKnown = 1<<10 | 1<<12 | 1<<13 | |
| 86 | + | return InheritedTheme(detected) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | |
| 90 | + | th := inheritedTestTheme(true) | |
| 91 | + | if !th.Inherited || th.ColorMode != ColorModeTrueColor { | |
| 92 | + | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want truecolor", th.Inherited, th.ColorMode) | |
| 93 | + | } | |
| 94 | + | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") { | |
| 95 | + | t.Fatalf("accent did not use the reported terminal palette: %q", got) | |
| 96 | + | } | |
| 97 | + | if got := th.FG256(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") { | |
| 98 | + | t.Fatalf("foreground did not inherit the terminal default: %q", got) | |
| 99 | + | } | |
| 100 | + | if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") { | |
| 101 | + | t.Fatalf("user bubble did not use truecolor background: %q", got) | |
| 102 | + | } | |
| 103 | + | if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); !strings.Contains(got, "\x1b[38;2;") { | |
| 104 | + | t.Fatalf("syntax highlighting did not use truecolor: %q", got) | |
| 105 | + | } | |
| 106 | + | if got := th.DimColor(th.FG, 45); got.Mode != terminalColorRGB { | |
| 107 | + | t.Fatalf("DimColor mode = %v, want RGB", got.Mode) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | |
| 112 | + | th := inheritedTestTheme(false) | |
| 113 | + | if !th.Inherited || th.ColorMode != ColorMode256 { | |
| 114 | + | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want 256", th.Inherited, th.ColorMode) | |
| 115 | + | } | |
| 116 | + | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") { | |
| 117 | + | t.Fatalf("accent did not use 256-color fallback: %q", got) | |
| 118 | + | } | |
| 119 | + | if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") { | |
| 120 | + | t.Fatalf("user bubble did not quantize its background: %q", got) | |
| 121 | + | } | |
| 122 | + | if got := th.DimColor(th.FG, 45); got.Mode != terminalColor256 { | |
| 123 | + | t.Fatalf("DimColor mode = %v, want 256", got.Mode) | |
| 124 | + | } | |
| 125 | + | if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); strings.Contains(got, "\x1b[38;2;") { | |
| 126 | + | t.Fatalf("syntax highlighting unexpectedly used truecolor: %q", got) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestExplicitThemesRemainIndexed(t *testing.T) { | |
| 131 | + | if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want { | |
| 132 | + | t.Fatalf("explicit theme output = %q, want %q", got, want) | |
| 133 | + | } | |
| 134 | + | if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") { | |
| 135 | + | t.Fatalf("explicit RGB bubble changed output mode: %q", got) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestAvailableThemesIncludesInherited(t *testing.T) { | |
| 140 | + | options := AvailableThemes(t.TempDir()) | |
| 141 | + | for _, option := range options { | |
| 142 | + | if option.Value == "inherited" { | |
| 143 | + | if option.Label != "inherited (from terminal)" || !option.Builtin { | |
| 144 | + | t.Fatalf("unexpected inherited option: %#v", option) | |
| 145 | + | } | |
| 146 | + | return | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | t.Fatal("available themes did not include inherited") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestIsLightThemePreservesExplicitDarkTheme(t *testing.T) { | |
| 153 | + | dark := Dark | |
| 154 | + | dark.Terminal.SchemeKnown = true | |
| 155 | + | dark.Terminal.Light = true | |
| 156 | + | if IsLightTheme(dark) { | |
| 157 | + | t.Fatal("explicit dark theme was reclassified from the terminal scheme") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | inherited := InheritedTheme(dark) | |
| 161 | + | if !IsLightTheme(inherited) { | |
| 162 | + | t.Fatal("inherited light terminal theme was not recognized as light") | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestRendererDetectsInheritedSelectionBackground(t *testing.T) { | |
| 167 | + | var buf bytes.Buffer | |
| 168 | + | r := NewRenderer(&buf) | |
| 169 | + | r.Resize(20, 2) | |
| 170 | + | r.SetTheme(inheritedTestTheme(true)) | |
| 171 | + | selected := r.theme.SelectionStyle() + "selected" + reset | |
| 172 | + | r.Draw([]string{"", selected}, -1, -1) | |
| 173 | + | buf.Reset() | |
| 174 | + | ||
| 175 | + | r.Draw([]string{"", "plain"}, -1, -1) | |
| 176 | + | if !strings.Contains(buf.String(), MoveTo(1, 1)) { | |
| 177 | + | t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String()) | |
| 178 | + | } | |
| 179 | + | } | |
| @@ -0,0 +1,179 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestDetectTrueColor(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | term string | |||
| 13 | + | colorTerm string | |||
| 14 | + | want bool | |||
| 15 | + | }{ | |||
| 16 | + | {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true}, | |||
| 17 | + | {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true}, | |||
| 18 | + | {name: "direct TERM", term: "xterm-direct", want: true}, | |||
| 19 | + | {name: "direct TERM suffix", term: "screen-direct", want: true}, | |||
| 20 | + | {name: "256 color TERM", term: "xterm-256color", colorTerm: "", want: false}, | |||
| 21 | + | {name: "unknown TERM", term: "dumb", colorTerm: "", want: false}, | |||
| 22 | + | } | |||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | if got := DetectTrueColor(tt.term, tt.colorTerm); got != tt.want { | |||
| 26 | + | t.Fatalf("DetectTrueColor(%q, %q) = %v, want %v", tt.term, tt.colorTerm, got, tt.want) | |||
| 27 | + | } | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestParseOSCColorResponse(t *testing.T) { | |||
| 33 | + | tests := []struct { | |||
| 34 | + | name string | |||
| 35 | + | raw string | |||
| 36 | + | kind int | |||
| 37 | + | slot int | |||
| 38 | + | want TerminalColor | |||
| 39 | + | }{ | |||
| 40 | + | { | |||
| 41 | + | name: "foreground hash", | |||
| 42 | + | raw: "\x1b]10;#123456\x07", | |||
| 43 | + | kind: 10, | |||
| 44 | + | want: ColorRGB(0x12, 0x34, 0x56), | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "background rgb", | |||
| 48 | + | raw: "\x1b]11;rgb:ffff/8000/0000\x1b\\", | |||
| 49 | + | kind: 11, | |||
| 50 | + | want: ColorRGB(255, 127, 0), | |||
| 51 | + | }, | |||
| 52 | + | { | |||
| 53 | + | name: "palette slot", | |||
| 54 | + | raw: "\x1b]4;12;rgb:12/34/56\x07", | |||
| 55 | + | kind: 4, | |||
| 56 | + | slot: 12, | |||
| 57 | + | want: ColorRGB(0x12, 0x34, 0x56), | |||
| 58 | + | }, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | got, ok := parseOSCColorResponse([]byte(tt.raw)) | |||
| 63 | + | if !ok { | |||
| 64 | + | t.Fatal("parseOSCColorResponse returned !ok") | |||
| 65 | + | } | |||
| 66 | + | if got.kind != tt.kind || got.slot != tt.slot || got.color != tt.want { | |||
| 67 | + | t.Fatalf("response = %#v, want kind=%d slot=%d color=%#v", got, tt.kind, tt.slot, tt.want) | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func inheritedTestTheme(trueColor bool) Theme { | |||
| 74 | + | detected := Dark | |||
| 75 | + | detected.Terminal = TerminalProfile{ | |||
| 76 | + | Foreground: ColorRGB(220, 210, 200), | |||
| 77 | + | Background: ColorRGB(12, 18, 24), | |||
| 78 | + | HasForeground: true, | |||
| 79 | + | HasBackground: true, | |||
| 80 | + | TrueColor: trueColor, | |||
| 81 | + | } | |||
| 82 | + | detected.Terminal.Palette[10] = ColorRGB(20, 220, 80) | |||
| 83 | + | detected.Terminal.Palette[12] = ColorRGB(40, 100, 240) | |||
| 84 | + | detected.Terminal.Palette[13] = ColorRGB(220, 70, 210) | |||
| 85 | + | detected.Terminal.PaletteKnown = 1<<10 | 1<<12 | 1<<13 | |||
| 86 | + | return InheritedTheme(detected) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | |||
| 90 | + | th := inheritedTestTheme(true) | |||
| 91 | + | if !th.Inherited || th.ColorMode != ColorModeTrueColor { | |||
| 92 | + | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want truecolor", th.Inherited, th.ColorMode) | |||
| 93 | + | } | |||
| 94 | + | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") { | |||
| 95 | + | t.Fatalf("accent did not use the reported terminal palette: %q", got) | |||
| 96 | + | } | |||
| 97 | + | if got := th.FG256(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") { | |||
| 98 | + | t.Fatalf("foreground did not inherit the terminal default: %q", got) | |||
| 99 | + | } | |||
| 100 | + | if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") { | |||
| 101 | + | t.Fatalf("user bubble did not use truecolor background: %q", got) | |||
| 102 | + | } | |||
| 103 | + | if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); !strings.Contains(got, "\x1b[38;2;") { | |||
| 104 | + | t.Fatalf("syntax highlighting did not use truecolor: %q", got) | |||
| 105 | + | } | |||
| 106 | + | if got := th.DimColor(th.FG, 45); got.Mode != terminalColorRGB { | |||
| 107 | + | t.Fatalf("DimColor mode = %v, want RGB", got.Mode) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | |||
| 112 | + | th := inheritedTestTheme(false) | |||
| 113 | + | if !th.Inherited || th.ColorMode != ColorMode256 { | |||
| 114 | + | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want 256", th.Inherited, th.ColorMode) | |||
| 115 | + | } | |||
| 116 | + | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") { | |||
| 117 | + | t.Fatalf("accent did not use 256-color fallback: %q", got) | |||
| 118 | + | } | |||
| 119 | + | if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") { | |||
| 120 | + | t.Fatalf("user bubble did not quantize its background: %q", got) | |||
| 121 | + | } | |||
| 122 | + | if got := th.DimColor(th.FG, 45); got.Mode != terminalColor256 { | |||
| 123 | + | t.Fatalf("DimColor mode = %v, want 256", got.Mode) | |||
| 124 | + | } | |||
| 125 | + | if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); strings.Contains(got, "\x1b[38;2;") { | |||
| 126 | + | t.Fatalf("syntax highlighting unexpectedly used truecolor: %q", got) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestExplicitThemesRemainIndexed(t *testing.T) { | |||
| 131 | + | if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want { | |||
| 132 | + | t.Fatalf("explicit theme output = %q, want %q", got, want) | |||
| 133 | + | } | |||
| 134 | + | if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") { | |||
| 135 | + | t.Fatalf("explicit RGB bubble changed output mode: %q", got) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestAvailableThemesIncludesInherited(t *testing.T) { | |||
| 140 | + | options := AvailableThemes(t.TempDir()) | |||
| 141 | + | for _, option := range options { | |||
| 142 | + | if option.Value == "inherited" { | |||
| 143 | + | if option.Label != "inherited (from terminal)" || !option.Builtin { | |||
| 144 | + | t.Fatalf("unexpected inherited option: %#v", option) | |||
| 145 | + | } | |||
| 146 | + | return | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | t.Fatal("available themes did not include inherited") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestIsLightThemePreservesExplicitDarkTheme(t *testing.T) { | |||
| 153 | + | dark := Dark | |||
| 154 | + | dark.Terminal.SchemeKnown = true | |||
| 155 | + | dark.Terminal.Light = true | |||
| 156 | + | if IsLightTheme(dark) { | |||
| 157 | + | t.Fatal("explicit dark theme was reclassified from the terminal scheme") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | inherited := InheritedTheme(dark) | |||
| 161 | + | if !IsLightTheme(inherited) { | |||
| 162 | + | t.Fatal("inherited light terminal theme was not recognized as light") | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestRendererDetectsInheritedSelectionBackground(t *testing.T) { | |||
| 167 | + | var buf bytes.Buffer | |||
| 168 | + | r := NewRenderer(&buf) | |||
| 169 | + | r.Resize(20, 2) | |||
| 170 | + | r.SetTheme(inheritedTestTheme(true)) | |||
| 171 | + | selected := r.theme.SelectionStyle() + "selected" + reset | |||
| 172 | + | r.Draw([]string{"", selected}, -1, -1) | |||
| 173 | + | buf.Reset() | |||
| 174 | + | ||||
| 175 | + | r.Draw([]string{"", "plain"}, -1, -1) | |||
| 176 | + | if !strings.Contains(buf.String(), MoveTo(1, 1)) { | |||
| 177 | + | t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String()) | |||
| 178 | + | } | |||
| 179 | + | } | |||
M packages/tui/theme_loader.go +141 -4
| @@ -172,6 +172,135 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error { | |||
| 172 | 172 | return nil | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | + | // InheritedTheme returns a theme derived from the terminal snapshot carried | |
| 176 | + | // by detected. It prefers the terminal's reported ANSI palette for semantic | |
| 177 | + | // accents, derives muted and selected surfaces by fading toward the terminal | |
| 178 | + | // background, and uses truecolor output only when the terminal advertises it. | |
| 179 | + | func InheritedTheme(detected Theme) Theme { | |
| 180 | + | base := Dark | |
| 181 | + | if detected.Terminal.SchemeKnown { | |
| 182 | + | if detected.Terminal.Light { | |
| 183 | + | base = Light | |
| 184 | + | } | |
| 185 | + | } else if IsLightTheme(detected) { | |
| 186 | + | base = Light | |
| 187 | + | } | |
| 188 | + | t := withTerminalProfile(base, detected) | |
| 189 | + | t.Inherited = true | |
| 190 | + | t.ColorMode = ColorMode256 | |
| 191 | + | if t.Terminal.TrueColor { | |
| 192 | + | t.ColorMode = ColorModeTrueColor | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | if t.Terminal.HasForeground { | |
| 196 | + | t.FG = terminalColorIndex(t.Terminal.Foreground) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | accentFallback := t.Accent | |
| 200 | + | if IsLightTheme(t) { | |
| 201 | + | accentFallback = Light.Accent | |
| 202 | + | } | |
| 203 | + | t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5) | |
| 204 | + | t.Assistant = t.Accent | |
| 205 | + | t.Tool = terminalPaletteIndex(t.Terminal, t.Tool, 10, 2) | |
| 206 | + | t.Error = terminalPaletteIndex(t.Terminal, t.Error, 9, 1) | |
| 207 | + | t.Warning = terminalPaletteIndex(t.Terminal, t.Warning, 11, 3) | |
| 208 | + | t.Spinner = terminalPaletteIndex(t.Terminal, t.Spinner, 13, 5) | |
| 209 | + | t.ThinkingMax = terminalPaletteIndex(t.Terminal, t.ThinkingMax, 13, 5) | |
| 210 | + | t.User = terminalPaletteIndex(t.Terminal, t.User, 11, 3) | |
| 211 | + | ||
| 212 | + | if t.Terminal.HasForeground && t.Terminal.HasBackground { | |
| 213 | + | t.Muted = terminalColorIndex(t.DimColor(t.FG, 45)) | |
| 214 | + | t.ToolOut = t.Muted | |
| 215 | + | background := t.Terminal.Background | |
| 216 | + | foreground := t.Terminal.Foreground | |
| 217 | + | t.UserBubbleBG = blendTerminalColors(background, foreground, 12) | |
| 218 | + | t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25)) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // FG256 receives an index rather than a semantic role. Keep the inherited | |
| 222 | + | // foreground index distinct from accent/status roles so a terminal whose | |
| 223 | + | // default foreground happens to be ANSI blue does not recolor every blue | |
| 224 | + | // accent as the terminal default foreground. | |
| 225 | + | if t.Terminal.HasForeground { | |
| 226 | + | used := map[int]bool{ | |
| 227 | + | t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true, | |
| 228 | + | t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true, | |
| 229 | + | t.ThinkingMax: true, t.User: true, t.SelectionBG: true, | |
| 230 | + | } | |
| 231 | + | if used[t.FG] { | |
| 232 | + | t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | t.UserBubbleFG = t.FG | |
| 236 | + | t.SelectionFG = t.FG | |
| 237 | + | ||
| 238 | + | // Keep the terminal's configured background untouched. Painting an | |
| 239 | + | // inherited RGB background across every row would make terminal theme | |
| 240 | + | // changes and scrollback behavior less predictable. | |
| 241 | + | t.Background = nil | |
| 242 | + | return t | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func withTerminalProfile(base, detected Theme) Theme { | |
| 246 | + | base.Terminal = detected.Terminal | |
| 247 | + | return base | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int { | |
| 251 | + | for _, index := range preferred { | |
| 252 | + | if _, ok := profile.PaletteColor(index); ok { | |
| 253 | + | return index | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | return fallback | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func terminalColorIndex(color TerminalColor) int { | |
| 260 | + | if color.Mode == terminalColor256 { | |
| 261 | + | return clampXtermIndex(color.Index) | |
| 262 | + | } | |
| 263 | + | if color.Mode == terminalColorANSI { | |
| 264 | + | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |
| 265 | + | return index | |
| 266 | + | } | |
| 267 | + | return 0 | |
| 268 | + | } | |
| 269 | + | return nearestXtermColor(color.R, color.G, color.B) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |
| 273 | + | fromRGB, _ := rgbForTerminalColor(from) | |
| 274 | + | toRGB, _ := rgbForTerminalColor(to) | |
| 275 | + | percent = clampPercent(percent) | |
| 276 | + | return ColorRGB( | |
| 277 | + | blendChannel(fromRGB[0], toRGB[0], percent), | |
| 278 | + | blendChannel(fromRGB[1], toRGB[1], percent), | |
| 279 | + | blendChannel(fromRGB[2], toRGB[2], percent), | |
| 280 | + | ) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func nearestXtermColorExcluding(color TerminalColor, excluded map[int]bool) int { | |
| 284 | + | rgb, _ := rgbForTerminalColor(color) | |
| 285 | + | best := 0 | |
| 286 | + | bestDistance := int(^uint(0) >> 1) | |
| 287 | + | for index := 0; index < 256; index++ { | |
| 288 | + | if excluded[index] { | |
| 289 | + | continue | |
| 290 | + | } | |
| 291 | + | cr, cg, cb := xterm256RGB(index) | |
| 292 | + | dr := rgb[0] - cr | |
| 293 | + | dg := rgb[1] - cg | |
| 294 | + | db := rgb[2] - cb | |
| 295 | + | distance := dr*dr + dg*dg + db*db | |
| 296 | + | if distance < bestDistance { | |
| 297 | + | best = index | |
| 298 | + | bestDistance = distance | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | return best | |
| 302 | + | } | |
| 303 | + | ||
| 175 | 304 | // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json | |
| 176 | 305 | // to detected. Empty/auto/default keeps the built-in detected theme. | |
| 177 | 306 | // If preferred is set, it may be a theme name, a basename without | |
| @@ -187,10 +316,12 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string | |||
| 187 | 316 | return detected, "", err | |
| 188 | 317 | } | |
| 189 | 318 | switch path { | |
| 319 | + | case "builtin:inherited": | |
| 320 | + | return InheritedTheme(detected), "inherited", nil | |
| 190 | 321 | case "builtin:dark": | |
| 191 | - | return Dark, "dark", nil | |
| 322 | + | return withTerminalProfile(Dark, detected), "dark", nil | |
| 192 | 323 | case "builtin:light": | |
| 193 | - | return Light, "light", nil | |
| 324 | + | return withTerminalProfile(Light, detected), "light", nil | |
| 194 | 325 | } | |
| 195 | 326 | b, err := os.ReadFile(path) | |
| 196 | 327 | if err != nil { | |
| @@ -226,10 +357,11 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string | |||
| 226 | 357 | func AvailableThemes(zotHome string) []ThemeOption { | |
| 227 | 358 | out := []ThemeOption{ | |
| 228 | 359 | {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true}, | |
| 360 | + | {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true}, | |
| 229 | 361 | {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true}, | |
| 230 | 362 | {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true}, | |
| 231 | 363 | } | |
| 232 | - | seen := map[string]bool{"auto": true, "dark": true, "light": true} | |
| 364 | + | seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true} | |
| 233 | 365 | paths, _ := themeFilesIn(filepath.Join(zotHome, "themes")) | |
| 234 | 366 | sort.Strings(paths) | |
| 235 | 367 | for _, path := range paths { | |
| @@ -299,6 +431,8 @@ func resolveThemePath(zotHome, preferred string) (string, error) { | |||
| 299 | 431 | switch strings.ToLower(preferred) { | |
| 300 | 432 | case "", "auto", "default", "system": | |
| 301 | 433 | return "", nil | |
| 434 | + | case "inherited", "terminal", "terminal-inherited": | |
| 435 | + | return "builtin:inherited", nil | |
| 302 | 436 | case "dark": | |
| 303 | 437 | return "builtin:dark", nil | |
| 304 | 438 | case "light": | |
| @@ -471,7 +605,10 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme { | |||
| 471 | 605 | } | |
| 472 | 606 | ||
| 473 | 607 | func IsLightTheme(th Theme) bool { | |
| 474 | - | return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG | |
| 608 | + | if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG { | |
| 609 | + | return true | |
| 610 | + | } | |
| 611 | + | return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light | |
| 475 | 612 | } | |
| 476 | 613 | ||
| 477 | 614 | func isLightTheme(th Theme) bool { return IsLightTheme(th) } | |
| @@ -172,6 +172,135 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error { | |||||
| 172 | return nil | 172 | return nil | ||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | + | // InheritedTheme returns a theme derived from the terminal snapshot carried | |||
| 176 | + | // by detected. It prefers the terminal's reported ANSI palette for semantic | |||
| 177 | + | // accents, derives muted and selected surfaces by fading toward the terminal | |||
| 178 | + | // background, and uses truecolor output only when the terminal advertises it. | |||
| 179 | + | func InheritedTheme(detected Theme) Theme { | |||
| 180 | + | base := Dark | |||
| 181 | + | if detected.Terminal.SchemeKnown { | |||
| 182 | + | if detected.Terminal.Light { | |||
| 183 | + | base = Light | |||
| 184 | + | } | |||
| 185 | + | } else if IsLightTheme(detected) { | |||
| 186 | + | base = Light | |||
| 187 | + | } | |||
| 188 | + | t := withTerminalProfile(base, detected) | |||
| 189 | + | t.Inherited = true | |||
| 190 | + | t.ColorMode = ColorMode256 | |||
| 191 | + | if t.Terminal.TrueColor { | |||
| 192 | + | t.ColorMode = ColorModeTrueColor | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | if t.Terminal.HasForeground { | |||
| 196 | + | t.FG = terminalColorIndex(t.Terminal.Foreground) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | accentFallback := t.Accent | |||
| 200 | + | if IsLightTheme(t) { | |||
| 201 | + | accentFallback = Light.Accent | |||
| 202 | + | } | |||
| 203 | + | t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5) | |||
| 204 | + | t.Assistant = t.Accent | |||
| 205 | + | t.Tool = terminalPaletteIndex(t.Terminal, t.Tool, 10, 2) | |||
| 206 | + | t.Error = terminalPaletteIndex(t.Terminal, t.Error, 9, 1) | |||
| 207 | + | t.Warning = terminalPaletteIndex(t.Terminal, t.Warning, 11, 3) | |||
| 208 | + | t.Spinner = terminalPaletteIndex(t.Terminal, t.Spinner, 13, 5) | |||
| 209 | + | t.ThinkingMax = terminalPaletteIndex(t.Terminal, t.ThinkingMax, 13, 5) | |||
| 210 | + | t.User = terminalPaletteIndex(t.Terminal, t.User, 11, 3) | |||
| 211 | + | ||||
| 212 | + | if t.Terminal.HasForeground && t.Terminal.HasBackground { | |||
| 213 | + | t.Muted = terminalColorIndex(t.DimColor(t.FG, 45)) | |||
| 214 | + | t.ToolOut = t.Muted | |||
| 215 | + | background := t.Terminal.Background | |||
| 216 | + | foreground := t.Terminal.Foreground | |||
| 217 | + | t.UserBubbleBG = blendTerminalColors(background, foreground, 12) | |||
| 218 | + | t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25)) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // FG256 receives an index rather than a semantic role. Keep the inherited | |||
| 222 | + | // foreground index distinct from accent/status roles so a terminal whose | |||
| 223 | + | // default foreground happens to be ANSI blue does not recolor every blue | |||
| 224 | + | // accent as the terminal default foreground. | |||
| 225 | + | if t.Terminal.HasForeground { | |||
| 226 | + | used := map[int]bool{ | |||
| 227 | + | t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true, | |||
| 228 | + | t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true, | |||
| 229 | + | t.ThinkingMax: true, t.User: true, t.SelectionBG: true, | |||
| 230 | + | } | |||
| 231 | + | if used[t.FG] { | |||
| 232 | + | t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | t.UserBubbleFG = t.FG | |||
| 236 | + | t.SelectionFG = t.FG | |||
| 237 | + | ||||
| 238 | + | // Keep the terminal's configured background untouched. Painting an | |||
| 239 | + | // inherited RGB background across every row would make terminal theme | |||
| 240 | + | // changes and scrollback behavior less predictable. | |||
| 241 | + | t.Background = nil | |||
| 242 | + | return t | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func withTerminalProfile(base, detected Theme) Theme { | |||
| 246 | + | base.Terminal = detected.Terminal | |||
| 247 | + | return base | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int { | |||
| 251 | + | for _, index := range preferred { | |||
| 252 | + | if _, ok := profile.PaletteColor(index); ok { | |||
| 253 | + | return index | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | return fallback | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func terminalColorIndex(color TerminalColor) int { | |||
| 260 | + | if color.Mode == terminalColor256 { | |||
| 261 | + | return clampXtermIndex(color.Index) | |||
| 262 | + | } | |||
| 263 | + | if color.Mode == terminalColorANSI { | |||
| 264 | + | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |||
| 265 | + | return index | |||
| 266 | + | } | |||
| 267 | + | return 0 | |||
| 268 | + | } | |||
| 269 | + | return nearestXtermColor(color.R, color.G, color.B) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |||
| 273 | + | fromRGB, _ := rgbForTerminalColor(from) | |||
| 274 | + | toRGB, _ := rgbForTerminalColor(to) | |||
| 275 | + | percent = clampPercent(percent) | |||
| 276 | + | return ColorRGB( | |||
| 277 | + | blendChannel(fromRGB[0], toRGB[0], percent), | |||
| 278 | + | blendChannel(fromRGB[1], toRGB[1], percent), | |||
| 279 | + | blendChannel(fromRGB[2], toRGB[2], percent), | |||
| 280 | + | ) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func nearestXtermColorExcluding(color TerminalColor, excluded map[int]bool) int { | |||
| 284 | + | rgb, _ := rgbForTerminalColor(color) | |||
| 285 | + | best := 0 | |||
| 286 | + | bestDistance := int(^uint(0) >> 1) | |||
| 287 | + | for index := 0; index < 256; index++ { | |||
| 288 | + | if excluded[index] { | |||
| 289 | + | continue | |||
| 290 | + | } | |||
| 291 | + | cr, cg, cb := xterm256RGB(index) | |||
| 292 | + | dr := rgb[0] - cr | |||
| 293 | + | dg := rgb[1] - cg | |||
| 294 | + | db := rgb[2] - cb | |||
| 295 | + | distance := dr*dr + dg*dg + db*db | |||
| 296 | + | if distance < bestDistance { | |||
| 297 | + | best = index | |||
| 298 | + | bestDistance = distance | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | return best | |||
| 302 | + | } | |||
| 303 | + | ||||
| 175 | // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json | 304 | // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json | ||
| 176 | // to detected. Empty/auto/default keeps the built-in detected theme. | 305 | // to detected. Empty/auto/default keeps the built-in detected theme. | ||
| 177 | // If preferred is set, it may be a theme name, a basename without | 306 | // If preferred is set, it may be a theme name, a basename without | ||
| @@ -187,10 +316,12 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string | |||||
| 187 | return detected, "", err | 316 | return detected, "", err | ||
| 188 | } | 317 | } | ||
| 189 | switch path { | 318 | switch path { | ||
| 319 | + | case "builtin:inherited": | |||
| 320 | + | return InheritedTheme(detected), "inherited", nil | |||
| 190 | case "builtin:dark": | 321 | case "builtin:dark": | ||
| 191 | - | return Dark, "dark", nil | 322 | + | return withTerminalProfile(Dark, detected), "dark", nil |
| 192 | case "builtin:light": | 323 | case "builtin:light": | ||
| 193 | - | return Light, "light", nil | 324 | + | return withTerminalProfile(Light, detected), "light", nil |
| 194 | } | 325 | } | ||
| 195 | b, err := os.ReadFile(path) | 326 | b, err := os.ReadFile(path) | ||
| 196 | if err != nil { | 327 | if err != nil { | ||
| @@ -226,10 +357,11 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string | |||||
| 226 | func AvailableThemes(zotHome string) []ThemeOption { | 357 | func AvailableThemes(zotHome string) []ThemeOption { | ||
| 227 | out := []ThemeOption{ | 358 | out := []ThemeOption{ | ||
| 228 | {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true}, | 359 | {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true}, | ||
| 360 | + | {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true}, | |||
| 229 | {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true}, | 361 | {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true}, | ||
| 230 | {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true}, | 362 | {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true}, | ||
| 231 | } | 363 | } | ||
| 232 | - | seen := map[string]bool{"auto": true, "dark": true, "light": true} | 364 | + | seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true} |
| 233 | paths, _ := themeFilesIn(filepath.Join(zotHome, "themes")) | 365 | paths, _ := themeFilesIn(filepath.Join(zotHome, "themes")) | ||
| 234 | sort.Strings(paths) | 366 | sort.Strings(paths) | ||
| 235 | for _, path := range paths { | 367 | for _, path := range paths { | ||
| @@ -299,6 +431,8 @@ func resolveThemePath(zotHome, preferred string) (string, error) { | |||||
| 299 | switch strings.ToLower(preferred) { | 431 | switch strings.ToLower(preferred) { | ||
| 300 | case "", "auto", "default", "system": | 432 | case "", "auto", "default", "system": | ||
| 301 | return "", nil | 433 | return "", nil | ||
| 434 | + | case "inherited", "terminal", "terminal-inherited": | |||
| 435 | + | return "builtin:inherited", nil | |||
| 302 | case "dark": | 436 | case "dark": | ||
| 303 | return "builtin:dark", nil | 437 | return "builtin:dark", nil | ||
| 304 | case "light": | 438 | case "light": | ||
| @@ -471,7 +605,10 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme { | |||||
| 471 | } | 605 | } | ||
| 472 | 606 | ||||
| 473 | func IsLightTheme(th Theme) bool { | 607 | func IsLightTheme(th Theme) bool { | ||
| 474 | - | return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG | 608 | + | if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG { |
| 609 | + | return true | |||
| 610 | + | } | |||
| 611 | + | return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light | |||
| 475 | } | 612 | } | ||
| 476 | 613 | ||||
| 477 | func isLightTheme(th Theme) bool { return IsLightTheme(th) } | 614 | func isLightTheme(th Theme) bool { return IsLightTheme(th) } | ||
M packages/tui/view.go +4 -4
| @@ -1196,7 +1196,7 @@ func compactToolBlank(th Theme, width int) string { | |||
| 1196 | 1196 | if width < 1 { | |
| 1197 | 1197 | width = 1 | |
| 1198 | 1198 | } | |
| 1199 | - | return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset | |
| 1199 | + | return th.sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset | |
| 1200 | 1200 | } | |
| 1201 | 1201 | ||
| 1202 | 1202 | func compactToolHeader(th Theme, label string, width int) string { | |
| @@ -1222,8 +1222,8 @@ func compactToolHeader(th Theme, label string, width int) string { | |||
| 1222 | 1222 | if visible < width { | |
| 1223 | 1223 | fill = strings.Repeat(" ", width-visible) | |
| 1224 | 1224 | } | |
| 1225 | - | bg := sgrBGColor(th.UserBubbleBG) | |
| 1226 | - | return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset | |
| 1225 | + | bg := th.sgrBGColor(th.UserBubbleBG) | |
| 1226 | + | return bg + pad + th.fgPrefix(th.FG) + name + bg + th.fgPrefix(th.Muted) + rest + bg + fill + reset | |
| 1227 | 1227 | } | |
| 1228 | 1228 | ||
| 1229 | 1229 | func toolBodyLine(th Theme, line string, width int, compact bool) string { | |
| @@ -1250,7 +1250,7 @@ func compactToolBody(th Theme, line string, width int) string { | |||
| 1250 | 1250 | if visible < width { | |
| 1251 | 1251 | fill = strings.Repeat(" ", width-visible) | |
| 1252 | 1252 | } | |
| 1253 | - | bg := sgrBGColor(th.UserBubbleBG) | |
| 1253 | + | bg := th.sgrBGColor(th.UserBubbleBG) | |
| 1254 | 1254 | return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset | |
| 1255 | 1255 | } | |
| 1256 | 1256 | ||
| @@ -1196,7 +1196,7 @@ func compactToolBlank(th Theme, width int) string { | |||||
| 1196 | if width < 1 { | 1196 | if width < 1 { | ||
| 1197 | width = 1 | 1197 | width = 1 | ||
| 1198 | } | 1198 | } | ||
| 1199 | - | return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset | 1199 | + | return th.sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset |
| 1200 | } | 1200 | } | ||
| 1201 | 1201 | ||||
| 1202 | func compactToolHeader(th Theme, label string, width int) string { | 1202 | func compactToolHeader(th Theme, label string, width int) string { | ||
| @@ -1222,8 +1222,8 @@ func compactToolHeader(th Theme, label string, width int) string { | |||||
| 1222 | if visible < width { | 1222 | if visible < width { | ||
| 1223 | fill = strings.Repeat(" ", width-visible) | 1223 | fill = strings.Repeat(" ", width-visible) | ||
| 1224 | } | 1224 | } | ||
| 1225 | - | bg := sgrBGColor(th.UserBubbleBG) | 1225 | + | bg := th.sgrBGColor(th.UserBubbleBG) |
| 1226 | - | return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset | 1226 | + | return bg + pad + th.fgPrefix(th.FG) + name + bg + th.fgPrefix(th.Muted) + rest + bg + fill + reset |
| 1227 | } | 1227 | } | ||
| 1228 | 1228 | ||||
| 1229 | func toolBodyLine(th Theme, line string, width int, compact bool) string { | 1229 | func toolBodyLine(th Theme, line string, width int, compact bool) string { | ||
| @@ -1250,7 +1250,7 @@ func compactToolBody(th Theme, line string, width int) string { | |||||
| 1250 | if visible < width { | 1250 | if visible < width { | ||
| 1251 | fill = strings.Repeat(" ", width-visible) | 1251 | fill = strings.Repeat(" ", width-visible) | ||
| 1252 | } | 1252 | } | ||
| 1253 | - | bg := sgrBGColor(th.UserBubbleBG) | 1253 | + | bg := th.sgrBGColor(th.UserBubbleBG) |
| 1254 | return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset | 1254 | return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset | ||
| 1255 | } | 1255 | } | ||
| 1256 | 1256 | ||||