detached 15085f3

Merge pull request #104 from bnema/feat/palette-theme

feat(theme): inherit terminal palette accents
commit
15085f3429603c7810092af4264cab001eaaa436
author
bnema · · 2026-07-17 12:04 UTC
committer
GitHub · · 2026-07-17 12:04 UTC
merge of
834b4af + d0f41bb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
30 files · +2188 -306
M docs/configuration.md +7 -1
@@ -3,8 +3,10 @@
33 vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed.
44
55 ```text
6-# Theme: auto follows the client; dark/light force built-in palettes.
6+# Theme: auto follows the client; dark/light use neutral built-in defaults.
77 theme = auto
8+# In auto mode, inherit the terminal's ANSI blue for chrome accents.
9+theme.palette = on
810
911 # Palette placement: auto, center, top-left, top, top-right, left, right,
1012 # bottom-left, bottom, or bottom-right.
@@ -66,6 +68,10 @@ code.detach = DET
6668
6769 Invalid values log a warning and keep the last valid value.
6870
71+## Theme
72+
73+With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only.
74+
6975 ## Bindings
7076
7177 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
@@ -3,8 +3,10 @@
3 vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed.3 vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed.
4 4
5 ```text5 ```text
6-# Theme: auto follows the client; dark/light force built-in palettes.6+# Theme: auto follows the client; dark/light use neutral built-in defaults.
7 theme = auto7 theme = auto
8+# In auto mode, inherit the terminal's ANSI blue for chrome accents.
9+theme.palette = on
8 10
9 # Palette placement: auto, center, top-left, top, top-right, left, right,11 # Palette placement: auto, center, top-left, top, top-right, left, right,
10 # bottom-left, bottom, or bottom-right.12 # bottom-left, bottom, or bottom-right.
@@ -66,6 +68,10 @@ code.detach = DET
66 68
67 Invalid values log a warning and keep the last valid value.69 Invalid values log a warning and keep the last valid value.
68 70
71+## Theme
72+
73+With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only.
74+
69 ## Bindings75 ## Bindings
70 76
71 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.77 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
M internal/adapters/config/config.go +7 -0
@@ -68,6 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6868 continue
6969 }
7070 cfg.Theme = mode
71+ case key == "theme.palette":
72+ on, ok := parseOnOff(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
75+ continue
76+ }
77+ cfg.ThemePalette = on
7178 case key == "bar.top-right":
7279 command, ok := parseBarCommand(value)
7380 if !ok {
@@ -68,6 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
68 continue68 continue
69 }69 }
70 cfg.Theme = mode70 cfg.Theme = mode
71+ case key == "theme.palette":
72+ on, ok := parseOnOff(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
75+ continue
76+ }
77+ cfg.ThemePalette = on
71 case key == "bar.top-right":78 case key == "bar.top-right":
72 command, ok := parseBarCommand(value)79 command, ok := parseBarCommand(value)
73 if !ok {80 if !ok {
M internal/adapters/config/config_test.go +55 -0
@@ -332,6 +332,9 @@ func TestParse(t *testing.T) {
332332 t.Run(tt.name, func(t *testing.T) {
333333 t.Parallel()
334334
335+ if !tt.want.ThemePalette {
336+ tt.want.ThemePalette = domain.Defaults().ThemePalette
337+ }
335338 if tt.want.Bar.Interval == 0 {
336339 tt.want.Bar = domain.Defaults().Bar
337340 }
@@ -364,6 +367,58 @@ func TestParse(t *testing.T) {
364367 }
365368 }
366369
370+func TestParseThemePalette(t *testing.T) {
371+ t.Parallel()
372+
373+ tests := []struct {
374+ name string
375+ input string
376+ want bool
377+ wantWarnings []domain.Warning
378+ }{
379+ {
380+ name: "on enables terminal palette inheritance",
381+ input: "theme.palette = on\n",
382+ want: true,
383+ },
384+ {
385+ name: "off disables terminal palette inheritance",
386+ input: "theme.palette = off\n",
387+ want: false,
388+ },
389+ {
390+ name: "invalid value warns once and keeps default",
391+ input: "theme.palette = maybe\n",
392+ want: true,
393+ wantWarnings: []domain.Warning{
394+ {Line: 1, Msg: "invalid theme.palette \"maybe\""},
395+ },
396+ },
397+ {
398+ name: "absent key keeps default",
399+ input: "theme = dark\n",
400+ want: true,
401+ },
402+ }
403+
404+ for _, tt := range tests {
405+ t.Run(tt.name, func(t *testing.T) {
406+ t.Parallel()
407+
408+ got, warnings, err := Parse(strings.NewReader(tt.input))
409+ require.NoError(t, err)
410+ require.Equal(t, tt.want, got.ThemePalette)
411+ require.Equal(t, tt.wantWarnings, warnings)
412+ })
413+ }
414+}
415+
416+func TestDefaultsThemePalette(t *testing.T) {
417+ t.Parallel()
418+
419+ require.True(t, domain.Defaults().ThemePalette)
420+}
421+
367422 func TestParseTabsTerminalTitle(t *testing.T) {
368423 t.Parallel()
369424
@@ -332,6 +332,9 @@ func TestParse(t *testing.T) {
332 t.Run(tt.name, func(t *testing.T) {332 t.Run(tt.name, func(t *testing.T) {
333 t.Parallel()333 t.Parallel()
334 334
335+ if !tt.want.ThemePalette {
336+ tt.want.ThemePalette = domain.Defaults().ThemePalette
337+ }
335 if tt.want.Bar.Interval == 0 {338 if tt.want.Bar.Interval == 0 {
336 tt.want.Bar = domain.Defaults().Bar339 tt.want.Bar = domain.Defaults().Bar
337 }340 }
@@ -364,6 +367,58 @@ func TestParse(t *testing.T) {
364 }367 }
365 }368 }
366 369
370+func TestParseThemePalette(t *testing.T) {
371+ t.Parallel()
372+
373+ tests := []struct {
374+ name string
375+ input string
376+ want bool
377+ wantWarnings []domain.Warning
378+ }{
379+ {
380+ name: "on enables terminal palette inheritance",
381+ input: "theme.palette = on\n",
382+ want: true,
383+ },
384+ {
385+ name: "off disables terminal palette inheritance",
386+ input: "theme.palette = off\n",
387+ want: false,
388+ },
389+ {
390+ name: "invalid value warns once and keeps default",
391+ input: "theme.palette = maybe\n",
392+ want: true,
393+ wantWarnings: []domain.Warning{
394+ {Line: 1, Msg: "invalid theme.palette \"maybe\""},
395+ },
396+ },
397+ {
398+ name: "absent key keeps default",
399+ input: "theme = dark\n",
400+ want: true,
401+ },
402+ }
403+
404+ for _, tt := range tests {
405+ t.Run(tt.name, func(t *testing.T) {
406+ t.Parallel()
407+
408+ got, warnings, err := Parse(strings.NewReader(tt.input))
409+ require.NoError(t, err)
410+ require.Equal(t, tt.want, got.ThemePalette)
411+ require.Equal(t, tt.wantWarnings, warnings)
412+ })
413+ }
414+}
415+
416+func TestDefaultsThemePalette(t *testing.T) {
417+ t.Parallel()
418+
419+ require.True(t, domain.Defaults().ThemePalette)
420+}
421+
367 func TestParseTabsTerminalTitle(t *testing.T) {422 func TestParseTabsTerminalTitle(t *testing.T) {
368 t.Parallel()423 t.Parallel()
369 424
M internal/adapters/term/terminal.go +5 -2
@@ -31,7 +31,9 @@ const (
3131 bracketedPasteDisable = "\x1b[?2004l"
3232 colorSchemeEnable = "\x1b[?2031h"
3333 colorSchemeDisable = "\x1b[?2031l"
34- oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
34+ oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
35+ oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
36+ oscColorQuery = oscDefaultColorQuery + oscPaletteQuery
3537 )
3638
3739 // bufSize is the batched writer's buffer capacity.
@@ -235,7 +237,8 @@ func (t *Terminal) stopResizeLocked() {
235237 t.resizeWG.Wait()
236238 }
237239
238-// QueryColors re-emits OSC 10/11 queries for default foreground/background.
240+// QueryColors re-emits OSC 10/11 queries for default foreground/background
241+// followed by one batched OSC 4 query for palette slots 0 through 15.
239242 func (t *Terminal) QueryColors() error {
240243 t.mu.Lock()
241244 defer t.mu.Unlock()
@@ -31,7 +31,9 @@ const (
31 bracketedPasteDisable = "\x1b[?2004l"31 bracketedPasteDisable = "\x1b[?2004l"
32 colorSchemeEnable = "\x1b[?2031h"32 colorSchemeEnable = "\x1b[?2031h"
33 colorSchemeDisable = "\x1b[?2031l"33 colorSchemeDisable = "\x1b[?2031l"
34- oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"34+ oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
35+ oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
36+ oscColorQuery = oscDefaultColorQuery + oscPaletteQuery
35 )37 )
36 38
37 // bufSize is the batched writer's buffer capacity.39 // bufSize is the batched writer's buffer capacity.
@@ -235,7 +237,8 @@ func (t *Terminal) stopResizeLocked() {
235 t.resizeWG.Wait()237 t.resizeWG.Wait()
236 }238 }
237 239
238-// QueryColors re-emits OSC 10/11 queries for default foreground/background.240+// QueryColors re-emits OSC 10/11 queries for default foreground/background
241+// followed by one batched OSC 4 query for palette slots 0 through 15.
239 func (t *Terminal) QueryColors() error {242 func (t *Terminal) QueryColors() error {
240 t.mu.Lock()243 t.mu.Lock()
241 defer t.mu.Unlock()244 defer t.mu.Unlock()
M internal/adapters/term/terminal_test.go +6 -4
@@ -10,6 +10,8 @@ import (
1010 "github.com/bnema/vev/internal/domain"
1111 )
1212
13+const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
14+
1315 // pipePair returns an os.Pipe and a goroutine that continuously copies
1416 // everything written to the write end into captured, closing done once
1517 // the write end is closed (EOF).
@@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6062 _ = outW.Close()
6163 <-done
6264
63- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
65+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
6466 if got := captured.String(); got != want {
6567 t.Fatalf("captured escapes = %q, want %q", got, want)
6668 }
@@ -101,7 +103,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101103 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102104 // cursor/alt-screen exits exactly once, regardless of how many times
103105 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
106+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
105107 if got := captured.String(); got != want {
106108 t.Fatalf("captured escapes = %q, want %q", got, want)
107109 }
@@ -124,8 +126,8 @@ func TestTerminal_QueryColors(t *testing.T) {
124126 }
125127 _ = outW.Close()
126128 <-done
127- if got := captured.String(); got != oscColorQuery {
128- t.Fatalf("captured = %q, want %q", got, oscColorQuery)
129+ if got := captured.String(); got != wantColorQueries {
130+ t.Fatalf("captured = %q, want %q", got, wantColorQueries)
129131 }
130132 }
131133
@@ -10,6 +10,8 @@ import (
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 )11 )
12 12
13+const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
14+
13 // pipePair returns an os.Pipe and a goroutine that continuously copies15 // pipePair returns an os.Pipe and a goroutine that continuously copies
14 // everything written to the write end into captured, closing done once16 // everything written to the write end into captured, closing done once
15 // the write end is closed (EOF).17 // the write end is closed (EOF).
@@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()62 _ = outW.Close()
61 <-done63 <-done
62 64
63- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit65+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
64 if got := captured.String(); got != want {66 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)67 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }68 }
@@ -101,7 +103,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and103 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102 // cursor/alt-screen exits exactly once, regardless of how many times104 // cursor/alt-screen exits exactly once, regardless of how many times
103 // EnterRaw/restore were called.105 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit106+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
105 if got := captured.String(); got != want {107 if got := captured.String(); got != want {
106 t.Fatalf("captured escapes = %q, want %q", got, want)108 t.Fatalf("captured escapes = %q, want %q", got, want)
107 }109 }
@@ -124,8 +126,8 @@ func TestTerminal_QueryColors(t *testing.T) {
124 }126 }
125 _ = outW.Close()127 _ = outW.Close()
126 <-done128 <-done
127- if got := captured.String(); got != oscColorQuery {129+ if got := captured.String(); got != wantColorQueries {
128- t.Fatalf("captured = %q, want %q", got, oscColorQuery)130+ t.Fatalf("captured = %q, want %q", got, wantColorQueries)
129 }131 }
130 }132 }
131 133
M internal/domain/config.go +3 -1
@@ -81,6 +81,7 @@ type TabsConfig struct {
8181 // layer can decide which actions it understands.
8282 type Config struct {
8383 Theme ThemeMode
84+ ThemePalette bool
8485 Bar BarConfig
8586 BindingEntries []ConfigEntry
8687 Codes map[string]string
@@ -101,7 +102,8 @@ type Warning struct {
101102 // Defaults returns vev's default configuration.
102103 func Defaults() Config {
103104 return Config{
104- Theme: ThemeAuto,
105+ Theme: ThemeAuto,
106+ ThemePalette: true,
105107 Bar: BarConfig{
106108 TopRight: "vev-bar-top-right",
107109 BottomRight: "vev-bar-bottom-right",
@@ -81,6 +81,7 @@ type TabsConfig struct {
81 // layer can decide which actions it understands.81 // layer can decide which actions it understands.
82 type Config struct {82 type Config struct {
83 Theme ThemeMode83 Theme ThemeMode
84+ ThemePalette bool
84 Bar BarConfig85 Bar BarConfig
85 BindingEntries []ConfigEntry86 BindingEntries []ConfigEntry
86 Codes map[string]string87 Codes map[string]string
@@ -101,7 +102,8 @@ type Warning struct {
101 // Defaults returns vev's default configuration.102 // Defaults returns vev's default configuration.
102 func Defaults() Config {103 func Defaults() Config {
103 return Config{104 return Config{
104- Theme: ThemeAuto,105+ Theme: ThemeAuto,
106+ ThemePalette: true,
105 Bar: BarConfig{107 Bar: BarConfig{
106 TopRight: "vev-bar-top-right",108 TopRight: "vev-bar-top-right",
107 BottomRight: "vev-bar-bottom-right",109 BottomRight: "vev-bar-bottom-right",
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 15
4+const ProtocolVersion uint16 = 16
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 154+const ProtocolVersion uint16 = 16
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 15 {
7- t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)
6+ if ProtocolVersion != 16 {
7+ t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 15 {6+ if ProtocolVersion != 16 {
7- t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +42 -11
@@ -85,8 +85,8 @@ type Resize struct {
8585 Size domain.Size
8686 }
8787
88-// Theme reports the client's terminal foreground/background colors and
89-// whether the client terminal supports truecolor.
88+// Theme reports the client's terminal foreground/background colors, ANSI
89+// palette entries, and whether the client terminal supports truecolor.
9090 type Theme struct {
9191 HasForeground bool
9292 Foreground renderer.RGB
@@ -95,6 +95,8 @@ type Theme struct {
9595 TrueColor bool
9696 SchemeKnown bool
9797 Light bool
98+ PaletteKnown uint16
99+ Palette [16]renderer.RGB
98100 }
99101
100102 // ImagePush carries a clipboard image from a remote client, to be written to
@@ -464,7 +466,7 @@ func MarshalResize(m Resize) []byte {
464466 return w.b
465467 }
466468
467-// MarshalTheme encodes m into a fixed-width Theme message payload.
469+// MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
468470 func MarshalTheme(m Theme) []byte {
469471 var flags uint8
470472 if m.HasForeground {
@@ -482,14 +484,25 @@ func MarshalTheme(m Theme) []byte {
482484 if m.Light {
483485 flags |= 0x10
484486 }
485- return []byte{
486- flags,
487- m.Foreground.R, m.Foreground.G, m.Foreground.B,
488- m.Background.R, m.Background.G, m.Background.B,
487+
488+ w := payloadWriter{b: make([]byte, 0, 57)}
489+ w.putUint8(flags)
490+ w.putUint8(m.Foreground.R)
491+ w.putUint8(m.Foreground.G)
492+ w.putUint8(m.Foreground.B)
493+ w.putUint8(m.Background.R)
494+ w.putUint8(m.Background.G)
495+ w.putUint8(m.Background.B)
496+ w.putUint16(m.PaletteKnown)
497+ for _, color := range m.Palette {
498+ w.putUint8(color.R)
499+ w.putUint8(color.G)
500+ w.putUint8(color.B)
489501 }
502+ return w.b
490503 }
491504
492-// UnmarshalTheme decodes a fixed-width Theme message payload.
505+// UnmarshalTheme decodes a 57-byte fixed-width Theme message payload.
493506 func UnmarshalTheme(b []byte) (Theme, error) {
494507 r := payloadReader{b: b}
495508 flags, err := r.getUint8()
@@ -520,10 +533,12 @@ func UnmarshalTheme(b []byte) (Theme, error) {
520533 if err != nil {
521534 return Theme{}, err
522535 }
523- if err := r.done(); err != nil {
536+ paletteKnown, err := r.getUint16()
537+ if err != nil {
524538 return Theme{}, err
525539 }
526- return Theme{
540+
541+ m := Theme{
527542 HasForeground: flags&0x01 != 0,
528543 Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},
529544 HasBackground: flags&0x02 != 0,
@@ -531,7 +546,23 @@ func UnmarshalTheme(b []byte) (Theme, error) {
531546 TrueColor: flags&0x04 != 0,
532547 SchemeKnown: flags&0x08 != 0,
533548 Light: flags&0x10 != 0,
534- }, nil
549+ PaletteKnown: paletteKnown,
550+ }
551+ for i := range m.Palette {
552+ if m.Palette[i].R, err = r.getUint8(); err != nil {
553+ return Theme{}, err
554+ }
555+ if m.Palette[i].G, err = r.getUint8(); err != nil {
556+ return Theme{}, err
557+ }
558+ if m.Palette[i].B, err = r.getUint8(); err != nil {
559+ return Theme{}, err
560+ }
561+ }
562+ if err := r.done(); err != nil {
563+ return Theme{}, err
564+ }
565+ return m, nil
535566 }
536567
537568 // UnmarshalResize decodes a Resize message payload.
@@ -85,8 +85,8 @@ type Resize struct {
85 Size domain.Size85 Size domain.Size
86 }86 }
87 87
88-// Theme reports the client's terminal foreground/background colors and88+// Theme reports the client's terminal foreground/background colors, ANSI
89-// whether the client terminal supports truecolor.89+// palette entries, and whether the client terminal supports truecolor.
90 type Theme struct {90 type Theme struct {
91 HasForeground bool91 HasForeground bool
92 Foreground renderer.RGB92 Foreground renderer.RGB
@@ -95,6 +95,8 @@ type Theme struct {
95 TrueColor bool95 TrueColor bool
96 SchemeKnown bool96 SchemeKnown bool
97 Light bool97 Light bool
98+ PaletteKnown uint16
99+ Palette [16]renderer.RGB
98 }100 }
99 101
100 // ImagePush carries a clipboard image from a remote client, to be written to102 // ImagePush carries a clipboard image from a remote client, to be written to
@@ -464,7 +466,7 @@ func MarshalResize(m Resize) []byte {
464 return w.b466 return w.b
465 }467 }
466 468
467-// MarshalTheme encodes m into a fixed-width Theme message payload.469+// MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
468 func MarshalTheme(m Theme) []byte {470 func MarshalTheme(m Theme) []byte {
469 var flags uint8471 var flags uint8
470 if m.HasForeground {472 if m.HasForeground {
@@ -482,14 +484,25 @@ func MarshalTheme(m Theme) []byte {
482 if m.Light {484 if m.Light {
483 flags |= 0x10485 flags |= 0x10
484 }486 }
485- return []byte{487+
486- flags,488+ w := payloadWriter{b: make([]byte, 0, 57)}
487- m.Foreground.R, m.Foreground.G, m.Foreground.B,489+ w.putUint8(flags)
488- m.Background.R, m.Background.G, m.Background.B,490+ w.putUint8(m.Foreground.R)
491+ w.putUint8(m.Foreground.G)
492+ w.putUint8(m.Foreground.B)
493+ w.putUint8(m.Background.R)
494+ w.putUint8(m.Background.G)
495+ w.putUint8(m.Background.B)
496+ w.putUint16(m.PaletteKnown)
497+ for _, color := range m.Palette {
498+ w.putUint8(color.R)
499+ w.putUint8(color.G)
500+ w.putUint8(color.B)
489 }501 }
502+ return w.b
490 }503 }
491 504
492-// UnmarshalTheme decodes a fixed-width Theme message payload.505+// UnmarshalTheme decodes a 57-byte fixed-width Theme message payload.
493 func UnmarshalTheme(b []byte) (Theme, error) {506 func UnmarshalTheme(b []byte) (Theme, error) {
494 r := payloadReader{b: b}507 r := payloadReader{b: b}
495 flags, err := r.getUint8()508 flags, err := r.getUint8()
@@ -520,10 +533,12 @@ func UnmarshalTheme(b []byte) (Theme, error) {
520 if err != nil {533 if err != nil {
521 return Theme{}, err534 return Theme{}, err
522 }535 }
523- if err := r.done(); err != nil {536+ paletteKnown, err := r.getUint16()
537+ if err != nil {
524 return Theme{}, err538 return Theme{}, err
525 }539 }
526- return Theme{540+
541+ m := Theme{
527 HasForeground: flags&0x01 != 0,542 HasForeground: flags&0x01 != 0,
528 Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},543 Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},
529 HasBackground: flags&0x02 != 0,544 HasBackground: flags&0x02 != 0,
@@ -531,7 +546,23 @@ func UnmarshalTheme(b []byte) (Theme, error) {
531 TrueColor: flags&0x04 != 0,546 TrueColor: flags&0x04 != 0,
532 SchemeKnown: flags&0x08 != 0,547 SchemeKnown: flags&0x08 != 0,
533 Light: flags&0x10 != 0,548 Light: flags&0x10 != 0,
534- }, nil549+ PaletteKnown: paletteKnown,
550+ }
551+ for i := range m.Palette {
552+ if m.Palette[i].R, err = r.getUint8(); err != nil {
553+ return Theme{}, err
554+ }
555+ if m.Palette[i].G, err = r.getUint8(); err != nil {
556+ return Theme{}, err
557+ }
558+ if m.Palette[i].B, err = r.getUint8(); err != nil {
559+ return Theme{}, err
560+ }
561+ }
562+ if err := r.done(); err != nil {
563+ return Theme{}, err
564+ }
565+ return m, nil
535 }566 }
536 567
537 // UnmarshalResize decodes a Resize message payload.568 // UnmarshalResize decodes a Resize message payload.
M internal/ports/wire_test.go +45 -29
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121121 Env: []string{"A=B", "XY=123"},
122122 })
123123 want := []byte{
124- 0x00, 0x0f, 0x00, // version, intent
124+ 0x00, 0x10, 0x00, // version, intent
125125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -244,63 +244,79 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
244244 }
245245
246246 func TestThemeGoldenAndRoundTrip(t *testing.T) {
247+ palette := [16]renderer.RGB{
248+ {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},
249+ {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},
250+ {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
251+ {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
252+ {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
253+ {R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33},
254+ {R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39},
255+ {R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f},
256+ }
257+ zeroes := make([]byte, 57)
247258 tests := []struct {
248259 name string
249260 msg Theme
250261 want []byte
251262 }{
252263 {
253- name: "foreground background truecolor dark scheme",
264+ name: "empty",
265+ msg: Theme{},
266+ want: zeroes,
267+ },
268+ {
269+ name: "foreground only",
254270 msg: Theme{
255271 HasForeground: true,
256- Foreground: renderer.RGB{R: 1, G: 2, B: 3},
257- HasBackground: true,
258- Background: renderer.RGB{R: 4, G: 5, B: 6},
259- TrueColor: true,
260- SchemeKnown: true,
272+ Foreground: renderer.RGB{R: 10, G: 20, B: 30},
273+ Background: renderer.RGB{R: 40, G: 50, B: 60},
261274 },
262- want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
275+ want: append([]byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, zeroes[7:]...),
263276 },
264277 {
265- name: "light scheme bit without known is preserved",
278+ name: "light flag without known scheme",
266279 msg: Theme{
267280 Light: true,
268281 },
269- want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
282+ want: append([]byte{0x10}, zeroes[1:]...),
270283 },
271284 {
272- name: "foreground only no truecolor",
285+ name: "full palette",
273286 msg: Theme{
274287 HasForeground: true,
275- Foreground: renderer.RGB{R: 10, G: 20, B: 30},
276- Background: renderer.RGB{R: 40, G: 50, B: 60},
288+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
289+ HasBackground: true,
290+ Background: renderer.RGB{R: 4, G: 5, B: 6},
291+ TrueColor: true,
292+ SchemeKnown: true,
293+ PaletteKnown: 0x8001,
294+ Palette: palette,
295+ },
296+ want: []byte{
297+ 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,
298+ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
299+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
300+ 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
301+ 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
302+ 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
303+ 0x3d, 0x3e, 0x3f,
277304 },
278- want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c},
279- },
280- {
281- name: "empty",
282- msg: Theme{},
283- want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
284305 },
285306 }
286307
287308 for _, tt := range tests {
288309 t.Run(tt.name, func(t *testing.T) {
289310 got := MarshalTheme(tt.msg)
290- if !bytes.Equal(got, tt.want) {
291- t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want)
292- }
311+ require.Len(t, got, 57)
312+ require.Equal(t, tt.want, got)
293313 back, err := UnmarshalTheme(got)
294- if err != nil {
295- t.Fatalf("UnmarshalTheme() error = %v", err)
296- }
297- if !reflect.DeepEqual(back, tt.msg) {
298- t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
299- }
314+ require.NoError(t, err)
315+ require.Equal(t, tt.msg, back)
300316 })
301317 }
302318
303- full := MarshalTheme(tests[0].msg)
319+ full := MarshalTheme(tests[len(tests)-1].msg)
304320 assertAllPrefixesFail(t, full, UnmarshalTheme)
305321 assertTrailingGarbageFails(t, full, UnmarshalTheme)
306322 }
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121 Env: []string{"A=B", "XY=123"},121 Env: []string{"A=B", "XY=123"},
122 })122 })
123 want := []byte{123 want := []byte{
124- 0x00, 0x0f, 0x00, // version, intent124+ 0x00, 0x10, 0x00, // version, intent
125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -244,63 +244,79 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
244 }244 }
245 245
246 func TestThemeGoldenAndRoundTrip(t *testing.T) {246 func TestThemeGoldenAndRoundTrip(t *testing.T) {
247+ palette := [16]renderer.RGB{
248+ {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},
249+ {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},
250+ {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
251+ {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
252+ {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
253+ {R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33},
254+ {R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39},
255+ {R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f},
256+ }
257+ zeroes := make([]byte, 57)
247 tests := []struct {258 tests := []struct {
248 name string259 name string
249 msg Theme260 msg Theme
250 want []byte261 want []byte
251 }{262 }{
252 {263 {
253- name: "foreground background truecolor dark scheme",264+ name: "empty",
265+ msg: Theme{},
266+ want: zeroes,
267+ },
268+ {
269+ name: "foreground only",
254 msg: Theme{270 msg: Theme{
255 HasForeground: true,271 HasForeground: true,
256- Foreground: renderer.RGB{R: 1, G: 2, B: 3},272+ Foreground: renderer.RGB{R: 10, G: 20, B: 30},
257- HasBackground: true,273+ Background: renderer.RGB{R: 40, G: 50, B: 60},
258- Background: renderer.RGB{R: 4, G: 5, B: 6},
259- TrueColor: true,
260- SchemeKnown: true,
261 },274 },
262- want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},275+ want: append([]byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, zeroes[7:]...),
263 },276 },
264 {277 {
265- name: "light scheme bit without known is preserved",278+ name: "light flag without known scheme",
266 msg: Theme{279 msg: Theme{
267 Light: true,280 Light: true,
268 },281 },
269- want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},282+ want: append([]byte{0x10}, zeroes[1:]...),
270 },283 },
271 {284 {
272- name: "foreground only no truecolor",285+ name: "full palette",
273 msg: Theme{286 msg: Theme{
274 HasForeground: true,287 HasForeground: true,
275- Foreground: renderer.RGB{R: 10, G: 20, B: 30},288+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
276- Background: renderer.RGB{R: 40, G: 50, B: 60},289+ HasBackground: true,
290+ Background: renderer.RGB{R: 4, G: 5, B: 6},
291+ TrueColor: true,
292+ SchemeKnown: true,
293+ PaletteKnown: 0x8001,
294+ Palette: palette,
295+ },
296+ want: []byte{
297+ 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,
298+ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
299+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
300+ 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
301+ 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
302+ 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
303+ 0x3d, 0x3e, 0x3f,
277 },304 },
278- want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c},
279- },
280- {
281- name: "empty",
282- msg: Theme{},
283- want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
284 },305 },
285 }306 }
286 307
287 for _, tt := range tests {308 for _, tt := range tests {
288 t.Run(tt.name, func(t *testing.T) {309 t.Run(tt.name, func(t *testing.T) {
289 got := MarshalTheme(tt.msg)310 got := MarshalTheme(tt.msg)
290- if !bytes.Equal(got, tt.want) {311+ require.Len(t, got, 57)
291- t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want)312+ require.Equal(t, tt.want, got)
292- }
293 back, err := UnmarshalTheme(got)313 back, err := UnmarshalTheme(got)
294- if err != nil {314+ require.NoError(t, err)
295- t.Fatalf("UnmarshalTheme() error = %v", err)315+ require.Equal(t, tt.msg, back)
296- }
297- if !reflect.DeepEqual(back, tt.msg) {
298- t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
299- }
300 })316 })
301 }317 }
302 318
303- full := MarshalTheme(tests[0].msg)319+ full := MarshalTheme(tests[len(tests)-1].msg)
304 assertAllPrefixesFail(t, full, UnmarshalTheme)320 assertAllPrefixesFail(t, full, UnmarshalTheme)
305 assertTrailingGarbageFails(t, full, UnmarshalTheme)321 assertTrailingGarbageFails(t, full, UnmarshalTheme)
306 }322 }
M internal/usecase/client/client.go +203 -23
@@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme {
7171 return s.theme
7272 }
7373
74-func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
74+func (s *terminalThemeState) snapshot() ports.Theme {
7575 s.mu.Lock()
7676 defer s.mu.Unlock()
77- theme := s.theme
78- return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown
77+ return s.theme
78+}
79+
80+// clearPalette invalidates entries from a previous terminal color query while
81+// retaining independently reported foreground, background, and scheme data.
82+func (s *terminalThemeState) clearPalette() ports.Theme {
83+ return s.update(func(theme *ports.Theme) {
84+ theme.PaletteKnown = 0
85+ theme.Palette = [16]renderer.RGB{}
86+ })
87+}
88+
89+func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
90+ theme := s.snapshot()
91+ return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0
7992 }
8093
8194 // milestones tracks lifecycle progress so a failure can report which stages
@@ -588,16 +601,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
588601 ackQueue := newCumulativeAckQueue()
589602 sendErrCh := make(chan error, 1)
590603
591- // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to
592- // the main loop, which is the only goroutine allowed to touch term.Out()/
593- // term.Flush(); QueryColors writes through the same batched writer, so
594- // calling it directly from the stdin pump would race the main loop's
595- // output writes.
596- colorQueryCh := make(chan struct{}, 1)
597- requestColors := func() {
604+ // Palette re-queries use a private-mode query as an ordered boundary. The
605+ // stdin pump drains terminal replies through its mode-specific response
606+ // before it permits the main loop to publish the cleared scheme and issue
607+ // QueryColors. This separates bytes already queued (including
608+ // Scanner.pending) from replacement replies without allowing a second
609+ // goroutine to write to the terminal.
610+ colorQueryCh := make(chan colorQueryRequest)
611+ paletteReadyCh := make(chan paletteQueryReady)
612+ requestColors := func() (colorQueryRequest, bool) {
613+ request := colorQueryRequest{boundaryWritten: make(chan struct{})}
598614 select {
599- case colorQueryCh <- struct{}{}:
600- default:
615+ case colorQueryCh <- request:
616+ case <-loopCtx.Done():
617+ return colorQueryRequest{}, false
618+ }
619+ select {
620+ case <-request.boundaryWritten:
621+ return request, true
622+ case <-loopCtx.Done():
623+ return colorQueryRequest{}, false
601624 }
602625 }
603626
@@ -606,7 +629,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
606629 clip = clipboard
607630 }
608631 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
609- go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors}).run()
632+ go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run()
610633 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
611634
612635 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -645,10 +668,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
645668 // token. Run closes this transport on the way out.
646669 return welcomedResult(errLinkOffline)
647670 }
648- case <-colorQueryCh:
671+ case request := <-colorQueryCh:
672+ if _, err := term.Out().Write(paletteBoundaryQuery); err != nil {
673+ return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))
674+ }
675+ if err := term.Flush(); err != nil {
676+ return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err))
677+ }
678+ close(request.boundaryWritten)
679+ case ready := <-paletteReadyCh:
680+ // The private-mode response proves all earlier replies have been
681+ // discarded by stdin. Publish the one cleared scheme snapshot before
682+ // emitting the query; stdin remains paused until QueryColors has
683+ // completed below.
684+ clearedTheme := themeState.snapshot()
685+ select {
686+ case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
687+ case <-loopCtx.Done():
688+ return welcomedResult(nil)
689+ }
649690 if err := term.QueryColors(); err != nil {
650691 log.Warn("querying terminal colors", "err", err)
651692 }
693+ close(ready.queryIssued)
652694 case r := <-recvCh:
653695 if r.err != nil {
654696 if errors.Is(r.err, io.EOF) {
@@ -806,6 +848,91 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
806848 // exits. That is harmless here — Run has already returned and restored the
807849 // terminal — and matches the standard pattern for stdin pumps; a
808850 // closable stdin duplicate could lift it later if ever needed.
851+type colorQueryRequest struct {
852+ boundaryWritten chan struct{}
853+}
854+
855+type paletteQueryReady struct {
856+ queryIssued chan struct{}
857+}
858+
859+type paletteDrain struct {
860+ pending []byte
861+}
862+
863+type palettePhase uint8
864+
865+const (
866+ paletteAccepting palettePhase = iota
867+ paletteBoundaryRequested
868+ paletteDraining
869+ paletteAwaitingQuery
870+)
871+
872+var paletteBoundaryQuery = []byte("\x1b[?2031$p")
873+
874+// paletteBoundaryResponses accepts valid DECRQM status values for private
875+// mode 2031. The private mode number correlates this reply with the query
876+// that starts a replacement palette generation.
877+var paletteBoundaryResponses = [][]byte{
878+ []byte("\x1b[?2031;0$y"),
879+ []byte("\x1b[?2031;1$y"),
880+ []byte("\x1b[?2031;2$y"),
881+ []byte("\x1b[?2031;3$y"),
882+ []byte("\x1b[?2031;4$y"),
883+}
884+
885+// scan consumes the private-mode boundary response while forwarding every
886+// other byte in order. It retains only a possible response prefix, so split
887+// replies are consumed without dropping a user's partial escape sequence on
888+// cancellation.
889+func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) {
890+ for _, b := range data {
891+ d.pending = append(d.pending, b)
892+ for len(d.pending) > 0 {
893+ matched := false
894+ possible := false
895+ for _, response := range paletteBoundaryResponses {
896+ if len(d.pending) > len(response) {
897+ continue
898+ }
899+ match := true
900+ for i := range d.pending {
901+ if d.pending[i] != response[i] {
902+ match = false
903+ break
904+ }
905+ }
906+ if !match {
907+ continue
908+ }
909+ if len(d.pending) == len(response) {
910+ matched = true
911+ } else {
912+ possible = true
913+ }
914+ }
915+ if matched {
916+ d.pending = nil
917+ onBoundary()
918+ break
919+ }
920+ if possible {
921+ break
922+ }
923+ onBytes(d.pending[:1])
924+ d.pending = d.pending[1:]
925+ }
926+ }
927+}
928+
929+func (d *paletteDrain) flush(onBytes func([]byte)) {
930+ if len(d.pending) != 0 {
931+ onBytes(d.pending)
932+ d.pending = nil
933+ }
934+}
935+
809936 type stdinPump struct {
810937 ctx context.Context
811938 cancel context.CancelFunc
@@ -815,7 +942,8 @@ type stdinPump struct {
815942 themeState *terminalThemeState
816943 clipboard ports.ClipboardReader
817944 logger *slog.Logger
818- requestColors func()
945+ requestColors func() (colorQueryRequest, bool)
946+ paletteReady chan<- paletteQueryReady
819947 }
820948
821949 func (p *stdinPump) run() {
@@ -873,14 +1001,38 @@ func (p *stdinPump) run() {
8731001 sink = ci.Scan
8741002 }
8751003
1004+ phase := paletteAccepting
1005+ var drain paletteDrain
8761006 for {
8771007 n, rerr := in.Read(buf)
8781008 if n > 0 {
879- sendTheme := func(current ports.Theme) {
880- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
1009+ themeChanged := false
1010+ queryNeeded := false
1011+ onBytes := func(data []byte) {
1012+ if phase != paletteDraining {
1013+ sink(data)
1014+ return
1015+ }
1016+ drain.scan(data, sink, func() {
1017+ phase = paletteAwaitingQuery
1018+ ready := paletteQueryReady{queryIssued: make(chan struct{})}
1019+ select {
1020+ case p.paletteReady <- ready:
1021+ case <-ctx.Done():
1022+ return
1023+ }
1024+ select {
1025+ case <-ready.queryIssued:
1026+ phase = paletteAccepting
1027+ case <-ctx.Done():
1028+ }
1029+ })
8811030 }
8821031 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
883- current := themeState.update(func(current *ports.Theme) {
1032+ if phase != paletteAccepting {
1033+ return
1034+ }
1035+ themeState.update(func(current *ports.Theme) {
8841036 switch kind {
8851037 case 10:
8861038 current.HasForeground = true
@@ -890,22 +1042,50 @@ func (p *stdinPump) run() {
8901042 current.Background = rgb
8911043 }
8921044 })
893- sendTheme(current)
1045+ themeChanged = true
1046+ }, func(slot int, rgb renderer.RGB) {
1047+ if phase != paletteAccepting {
1048+ return
1049+ }
1050+ themeState.update(func(current *ports.Theme) {
1051+ current.PaletteKnown |= uint16(1) << slot
1052+ current.Palette[slot] = rgb
1053+ })
1054+ themeChanged = true
8941055 }, func(light bool) {
895- current := themeState.update(func(current *ports.Theme) {
1056+ themeState.update(func(current *ports.Theme) {
8961057 current.SchemeKnown = true
8971058 current.Light = light
8981059 })
1060+ // The scheme itself begins a new palette generation. Suppress every
1061+ // color reply after it, including those remaining in this read, and
1062+ // defer invalidation until Scan completes so no partial snapshot is
1063+ // observable mid-chunk.
1064+ themeChanged = false
1065+ if phase == paletteAccepting {
1066+ phase = paletteBoundaryRequested
1067+ queryNeeded = true
1068+ }
1069+ }, onBytes)
1070+ if queryNeeded {
1071+ themeState.clearPalette()
8991072 if requestColors != nil {
900- requestColors()
1073+ if _, ok := requestColors(); !ok {
1074+ return
1075+ }
1076+ phase = paletteDraining
9011077 }
902- sendTheme(current)
903- }, sink)
1078+ } else if themeChanged {
1079+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())})
1080+ }
9041081 if !sendOK.Load() {
9051082 return
9061083 }
9071084 }
9081085 if rerr != nil {
1086+ if phase == paletteDraining {
1087+ drain.flush(sink)
1088+ }
9091089 // Best-effort detach notification, then unwind.
9101090 select {
9111091 case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
@@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme {
71 return s.theme71 return s.theme
72 }72 }
73 73
74-func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {74+func (s *terminalThemeState) snapshot() ports.Theme {
75 s.mu.Lock()75 s.mu.Lock()
76 defer s.mu.Unlock()76 defer s.mu.Unlock()
77- theme := s.theme77+ return s.theme
78- return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown78+}
79+
80+// clearPalette invalidates entries from a previous terminal color query while
81+// retaining independently reported foreground, background, and scheme data.
82+func (s *terminalThemeState) clearPalette() ports.Theme {
83+ return s.update(func(theme *ports.Theme) {
84+ theme.PaletteKnown = 0
85+ theme.Palette = [16]renderer.RGB{}
86+ })
87+}
88+
89+func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
90+ theme := s.snapshot()
91+ return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0
79 }92 }
80 93
81 // milestones tracks lifecycle progress so a failure can report which stages94 // milestones tracks lifecycle progress so a failure can report which stages
@@ -588,16 +601,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
588 ackQueue := newCumulativeAckQueue()601 ackQueue := newCumulativeAckQueue()
589 sendErrCh := make(chan error, 1)602 sendErrCh := make(chan error, 1)
590 603
591- // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to604+ // Palette re-queries use a private-mode query as an ordered boundary. The
592- // the main loop, which is the only goroutine allowed to touch term.Out()/605+ // stdin pump drains terminal replies through its mode-specific response
593- // term.Flush(); QueryColors writes through the same batched writer, so606+ // before it permits the main loop to publish the cleared scheme and issue
594- // calling it directly from the stdin pump would race the main loop's607+ // QueryColors. This separates bytes already queued (including
595- // output writes.608+ // Scanner.pending) from replacement replies without allowing a second
596- colorQueryCh := make(chan struct{}, 1)609+ // goroutine to write to the terminal.
597- requestColors := func() {610+ colorQueryCh := make(chan colorQueryRequest)
611+ paletteReadyCh := make(chan paletteQueryReady)
612+ requestColors := func() (colorQueryRequest, bool) {
613+ request := colorQueryRequest{boundaryWritten: make(chan struct{})}
598 select {614 select {
599- case colorQueryCh <- struct{}{}:615+ case colorQueryCh <- request:
600- default:616+ case <-loopCtx.Done():
617+ return colorQueryRequest{}, false
618+ }
619+ select {
620+ case <-request.boundaryWritten:
621+ return request, true
622+ case <-loopCtx.Done():
623+ return colorQueryRequest{}, false
601 }624 }
602 }625 }
603 626
@@ -606,7 +629,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
606 clip = clipboard629 clip = clipboard
607 }630 }
608 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)631 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
609- go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors}).run()632+ go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run()
610 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)633 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
611 634
612 // 5. Output/main loop: the only goroutine that touches the terminal.635 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -645,10 +668,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
645 // token. Run closes this transport on the way out.668 // token. Run closes this transport on the way out.
646 return welcomedResult(errLinkOffline)669 return welcomedResult(errLinkOffline)
647 }670 }
648- case <-colorQueryCh:671+ case request := <-colorQueryCh:
672+ if _, err := term.Out().Write(paletteBoundaryQuery); err != nil {
673+ return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))
674+ }
675+ if err := term.Flush(); err != nil {
676+ return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err))
677+ }
678+ close(request.boundaryWritten)
679+ case ready := <-paletteReadyCh:
680+ // The private-mode response proves all earlier replies have been
681+ // discarded by stdin. Publish the one cleared scheme snapshot before
682+ // emitting the query; stdin remains paused until QueryColors has
683+ // completed below.
684+ clearedTheme := themeState.snapshot()
685+ select {
686+ case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
687+ case <-loopCtx.Done():
688+ return welcomedResult(nil)
689+ }
649 if err := term.QueryColors(); err != nil {690 if err := term.QueryColors(); err != nil {
650 log.Warn("querying terminal colors", "err", err)691 log.Warn("querying terminal colors", "err", err)
651 }692 }
693+ close(ready.queryIssued)
652 case r := <-recvCh:694 case r := <-recvCh:
653 if r.err != nil {695 if r.err != nil {
654 if errors.Is(r.err, io.EOF) {696 if errors.Is(r.err, io.EOF) {
@@ -806,6 +848,91 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
806 // exits. That is harmless here — Run has already returned and restored the848 // exits. That is harmless here — Run has already returned and restored the
807 // terminal — and matches the standard pattern for stdin pumps; a849 // terminal — and matches the standard pattern for stdin pumps; a
808 // closable stdin duplicate could lift it later if ever needed.850 // closable stdin duplicate could lift it later if ever needed.
851+type colorQueryRequest struct {
852+ boundaryWritten chan struct{}
853+}
854+
855+type paletteQueryReady struct {
856+ queryIssued chan struct{}
857+}
858+
859+type paletteDrain struct {
860+ pending []byte
861+}
862+
863+type palettePhase uint8
864+
865+const (
866+ paletteAccepting palettePhase = iota
867+ paletteBoundaryRequested
868+ paletteDraining
869+ paletteAwaitingQuery
870+)
871+
872+var paletteBoundaryQuery = []byte("\x1b[?2031$p")
873+
874+// paletteBoundaryResponses accepts valid DECRQM status values for private
875+// mode 2031. The private mode number correlates this reply with the query
876+// that starts a replacement palette generation.
877+var paletteBoundaryResponses = [][]byte{
878+ []byte("\x1b[?2031;0$y"),
879+ []byte("\x1b[?2031;1$y"),
880+ []byte("\x1b[?2031;2$y"),
881+ []byte("\x1b[?2031;3$y"),
882+ []byte("\x1b[?2031;4$y"),
883+}
884+
885+// scan consumes the private-mode boundary response while forwarding every
886+// other byte in order. It retains only a possible response prefix, so split
887+// replies are consumed without dropping a user's partial escape sequence on
888+// cancellation.
889+func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) {
890+ for _, b := range data {
891+ d.pending = append(d.pending, b)
892+ for len(d.pending) > 0 {
893+ matched := false
894+ possible := false
895+ for _, response := range paletteBoundaryResponses {
896+ if len(d.pending) > len(response) {
897+ continue
898+ }
899+ match := true
900+ for i := range d.pending {
901+ if d.pending[i] != response[i] {
902+ match = false
903+ break
904+ }
905+ }
906+ if !match {
907+ continue
908+ }
909+ if len(d.pending) == len(response) {
910+ matched = true
911+ } else {
912+ possible = true
913+ }
914+ }
915+ if matched {
916+ d.pending = nil
917+ onBoundary()
918+ break
919+ }
920+ if possible {
921+ break
922+ }
923+ onBytes(d.pending[:1])
924+ d.pending = d.pending[1:]
925+ }
926+ }
927+}
928+
929+func (d *paletteDrain) flush(onBytes func([]byte)) {
930+ if len(d.pending) != 0 {
931+ onBytes(d.pending)
932+ d.pending = nil
933+ }
934+}
935+
809 type stdinPump struct {936 type stdinPump struct {
810 ctx context.Context937 ctx context.Context
811 cancel context.CancelFunc938 cancel context.CancelFunc
@@ -815,7 +942,8 @@ type stdinPump struct {
815 themeState *terminalThemeState942 themeState *terminalThemeState
816 clipboard ports.ClipboardReader943 clipboard ports.ClipboardReader
817 logger *slog.Logger944 logger *slog.Logger
818- requestColors func()945+ requestColors func() (colorQueryRequest, bool)
946+ paletteReady chan<- paletteQueryReady
819 }947 }
820 948
821 func (p *stdinPump) run() {949 func (p *stdinPump) run() {
@@ -873,14 +1001,38 @@ func (p *stdinPump) run() {
873 sink = ci.Scan1001 sink = ci.Scan
874 }1002 }
875 1003
1004+ phase := paletteAccepting
1005+ var drain paletteDrain
876 for {1006 for {
877 n, rerr := in.Read(buf)1007 n, rerr := in.Read(buf)
878 if n > 0 {1008 if n > 0 {
879- sendTheme := func(current ports.Theme) {1009+ themeChanged := false
880- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})1010+ queryNeeded := false
1011+ onBytes := func(data []byte) {
1012+ if phase != paletteDraining {
1013+ sink(data)
1014+ return
1015+ }
1016+ drain.scan(data, sink, func() {
1017+ phase = paletteAwaitingQuery
1018+ ready := paletteQueryReady{queryIssued: make(chan struct{})}
1019+ select {
1020+ case p.paletteReady <- ready:
1021+ case <-ctx.Done():
1022+ return
1023+ }
1024+ select {
1025+ case <-ready.queryIssued:
1026+ phase = paletteAccepting
1027+ case <-ctx.Done():
1028+ }
1029+ })
881 }1030 }
882 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {1031 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
883- current := themeState.update(func(current *ports.Theme) {1032+ if phase != paletteAccepting {
1033+ return
1034+ }
1035+ themeState.update(func(current *ports.Theme) {
884 switch kind {1036 switch kind {
885 case 10:1037 case 10:
886 current.HasForeground = true1038 current.HasForeground = true
@@ -890,22 +1042,50 @@ func (p *stdinPump) run() {
890 current.Background = rgb1042 current.Background = rgb
891 }1043 }
892 })1044 })
893- sendTheme(current)1045+ themeChanged = true
1046+ }, func(slot int, rgb renderer.RGB) {
1047+ if phase != paletteAccepting {
1048+ return
1049+ }
1050+ themeState.update(func(current *ports.Theme) {
1051+ current.PaletteKnown |= uint16(1) << slot
1052+ current.Palette[slot] = rgb
1053+ })
1054+ themeChanged = true
894 }, func(light bool) {1055 }, func(light bool) {
895- current := themeState.update(func(current *ports.Theme) {1056+ themeState.update(func(current *ports.Theme) {
896 current.SchemeKnown = true1057 current.SchemeKnown = true
897 current.Light = light1058 current.Light = light
898 })1059 })
1060+ // The scheme itself begins a new palette generation. Suppress every
1061+ // color reply after it, including those remaining in this read, and
1062+ // defer invalidation until Scan completes so no partial snapshot is
1063+ // observable mid-chunk.
1064+ themeChanged = false
1065+ if phase == paletteAccepting {
1066+ phase = paletteBoundaryRequested
1067+ queryNeeded = true
1068+ }
1069+ }, onBytes)
1070+ if queryNeeded {
1071+ themeState.clearPalette()
899 if requestColors != nil {1072 if requestColors != nil {
900- requestColors()1073+ if _, ok := requestColors(); !ok {
1074+ return
1075+ }
1076+ phase = paletteDraining
901 }1077 }
902- sendTheme(current)1078+ } else if themeChanged {
903- }, sink)1079+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())})
1080+ }
904 if !sendOK.Load() {1081 if !sendOK.Load() {
905 return1082 return
906 }1083 }
907 }1084 }
908 if rerr != nil {1085 if rerr != nil {
1086+ if phase == paletteDraining {
1087+ drain.flush(sink)
1088+ }
909 // Best-effort detach notification, then unwind.1089 // Best-effort detach notification, then unwind.
910 select {1090 select {
911 case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:1091 case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
M internal/usecase/client/client_test.go +148 -0
@@ -1047,3 +1047,151 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
10471047 require.Equal(t, int32(1), d.calls.Load())
10481048 require.Equal(t, int32(1), term.restoreCount.Load())
10491049 }
1050+
1051+func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) {
1052+ input := newChunkedBlockingReader(
1053+ []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"),
1054+ []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"),
1055+ []byte("2$yc\x1b]4;3;#778899\ad"),
1056+ )
1057+ defer input.unblock()
1058+
1059+ var out bytes.Buffer
1060+ var restoreCount atomic.Int32
1061+ resizeCh := make(chan domain.Size)
1062+ tm := portsmocks.NewMockTerminal(t)
1063+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1064+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1065+ tm.EXPECT().In().Return(input).Maybe()
1066+ tm.EXPECT().Out().Return(&out).Maybe()
1067+ tm.EXPECT().Flush().Return(nil).Maybe()
1068+ tm.EXPECT().QueryColors().Return(nil).Once()
1069+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1070+
1071+ var inputBytes []byte
1072+ var inputMu sync.Mutex
1073+ themes := make(chan ports.Theme, 2)
1074+ freshTheme := make(chan struct{})
1075+ var freshOnce sync.Once
1076+ tr := portsmocks.NewMockTransport(t)
1077+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1078+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
1079+ in, err := ports.UnmarshalInput(f.Payload)
1080+ if err != nil {
1081+ return err
1082+ }
1083+ inputMu.Lock()
1084+ inputBytes = append(inputBytes, in.Data...)
1085+ inputMu.Unlock()
1086+ return nil
1087+ }).Maybe()
1088+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1089+ theme, err := ports.UnmarshalTheme(f.Payload)
1090+ if err != nil {
1091+ return err
1092+ }
1093+ themes <- theme
1094+ if theme.PaletteKnown == 1<<3 {
1095+ freshOnce.Do(func() { close(freshTheme) })
1096+ }
1097+ return nil
1098+ }).Times(2)
1099+ var welcomed atomic.Bool
1100+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1101+ if welcomed.CompareAndSwap(false, true) {
1102+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil
1103+ }
1104+ <-freshTheme
1105+ return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil
1106+ }).Maybe()
1107+ tr.EXPECT().Close().Return(nil).Once()
1108+
1109+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1110+ require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write")
1111+ require.Equal(t, int32(1), restoreCount.Load())
1112+
1113+ cleared := <-themes
1114+ fresh := <-themes
1115+ require.True(t, cleared.SchemeKnown)
1116+ require.True(t, cleared.Light)
1117+ require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary")
1118+ require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted")
1119+ require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation")
1120+ inputMu.Lock()
1121+ require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive")
1122+ inputMu.Unlock()
1123+}
1124+
1125+func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1126+ var out bytes.Buffer
1127+ var restoreCount atomic.Int32
1128+ resizeCh := make(chan domain.Size)
1129+ input := newChunkedBlockingReader(
1130+ []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1131+ []byte("\x1b[?997;2n"),
1132+ []byte("\x1b[?2031;1$y"),
1133+ )
1134+ defer input.unblock()
1135+
1136+ tm := portsmocks.NewMockTerminal(t)
1137+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1138+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1139+ tm.EXPECT().In().Return(input).Maybe()
1140+ tm.EXPECT().Out().Return(&out).Maybe()
1141+ tm.EXPECT().Flush().Return(nil).Maybe()
1142+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1143+ tm.EXPECT().QueryColors().Return(nil).Once()
1144+
1145+ var themes []ports.Theme
1146+ var themesMu sync.Mutex
1147+ clearedPalette := make(chan struct{})
1148+ var clearOnce sync.Once
1149+ tr := portsmocks.NewMockTransport(t)
1150+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1151+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1152+ theme, err := ports.UnmarshalTheme(f.Payload)
1153+ if err != nil {
1154+ t.Errorf("decode sent theme: %v", err)
1155+ return err
1156+ }
1157+ themesMu.Lock()
1158+ themes = append(themes, theme)
1159+ themesMu.Unlock()
1160+ if theme.SchemeKnown && theme.PaletteKnown == 0 {
1161+ clearOnce.Do(func() { close(clearedPalette) })
1162+ }
1163+ return nil
1164+ }).Times(2)
1165+
1166+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
1167+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
1168+ closed := make(chan struct{})
1169+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1170+ select {
1171+ case <-closed:
1172+ return ports.Frame{}, io.EOF
1173+ default:
1174+ }
1175+ select {
1176+ case <-clearedPalette:
1177+ close(closed)
1178+ return detached, nil
1179+ default:
1180+ return welcome, nil
1181+ }
1182+ }).Maybe()
1183+ tr.EXPECT().Close().Return(nil).Once()
1184+
1185+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1186+ require.Equal(t, int32(1), restoreCount.Load())
1187+
1188+ themesMu.Lock()
1189+ defer themesMu.Unlock()
1190+ require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent")
1191+ require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)
1192+ require.True(t, themes[0].HasBackground)
1193+ require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries")
1194+ require.Equal(t, themes[0].Background, themes[1].Background)
1195+ require.True(t, themes[1].SchemeKnown)
1196+ require.True(t, themes[1].Light)
1197+}
@@ -1047,3 +1047,151 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
1047 require.Equal(t, int32(1), d.calls.Load())1047 require.Equal(t, int32(1), d.calls.Load())
1048 require.Equal(t, int32(1), term.restoreCount.Load())1048 require.Equal(t, int32(1), term.restoreCount.Load())
1049 }1049 }
1050+
1051+func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) {
1052+ input := newChunkedBlockingReader(
1053+ []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"),
1054+ []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"),
1055+ []byte("2$yc\x1b]4;3;#778899\ad"),
1056+ )
1057+ defer input.unblock()
1058+
1059+ var out bytes.Buffer
1060+ var restoreCount atomic.Int32
1061+ resizeCh := make(chan domain.Size)
1062+ tm := portsmocks.NewMockTerminal(t)
1063+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1064+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1065+ tm.EXPECT().In().Return(input).Maybe()
1066+ tm.EXPECT().Out().Return(&out).Maybe()
1067+ tm.EXPECT().Flush().Return(nil).Maybe()
1068+ tm.EXPECT().QueryColors().Return(nil).Once()
1069+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1070+
1071+ var inputBytes []byte
1072+ var inputMu sync.Mutex
1073+ themes := make(chan ports.Theme, 2)
1074+ freshTheme := make(chan struct{})
1075+ var freshOnce sync.Once
1076+ tr := portsmocks.NewMockTransport(t)
1077+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1078+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
1079+ in, err := ports.UnmarshalInput(f.Payload)
1080+ if err != nil {
1081+ return err
1082+ }
1083+ inputMu.Lock()
1084+ inputBytes = append(inputBytes, in.Data...)
1085+ inputMu.Unlock()
1086+ return nil
1087+ }).Maybe()
1088+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1089+ theme, err := ports.UnmarshalTheme(f.Payload)
1090+ if err != nil {
1091+ return err
1092+ }
1093+ themes <- theme
1094+ if theme.PaletteKnown == 1<<3 {
1095+ freshOnce.Do(func() { close(freshTheme) })
1096+ }
1097+ return nil
1098+ }).Times(2)
1099+ var welcomed atomic.Bool
1100+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1101+ if welcomed.CompareAndSwap(false, true) {
1102+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil
1103+ }
1104+ <-freshTheme
1105+ return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil
1106+ }).Maybe()
1107+ tr.EXPECT().Close().Return(nil).Once()
1108+
1109+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1110+ require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write")
1111+ require.Equal(t, int32(1), restoreCount.Load())
1112+
1113+ cleared := <-themes
1114+ fresh := <-themes
1115+ require.True(t, cleared.SchemeKnown)
1116+ require.True(t, cleared.Light)
1117+ require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary")
1118+ require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted")
1119+ require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation")
1120+ inputMu.Lock()
1121+ require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive")
1122+ inputMu.Unlock()
1123+}
1124+
1125+func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1126+ var out bytes.Buffer
1127+ var restoreCount atomic.Int32
1128+ resizeCh := make(chan domain.Size)
1129+ input := newChunkedBlockingReader(
1130+ []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1131+ []byte("\x1b[?997;2n"),
1132+ []byte("\x1b[?2031;1$y"),
1133+ )
1134+ defer input.unblock()
1135+
1136+ tm := portsmocks.NewMockTerminal(t)
1137+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1138+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1139+ tm.EXPECT().In().Return(input).Maybe()
1140+ tm.EXPECT().Out().Return(&out).Maybe()
1141+ tm.EXPECT().Flush().Return(nil).Maybe()
1142+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1143+ tm.EXPECT().QueryColors().Return(nil).Once()
1144+
1145+ var themes []ports.Theme
1146+ var themesMu sync.Mutex
1147+ clearedPalette := make(chan struct{})
1148+ var clearOnce sync.Once
1149+ tr := portsmocks.NewMockTransport(t)
1150+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1151+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1152+ theme, err := ports.UnmarshalTheme(f.Payload)
1153+ if err != nil {
1154+ t.Errorf("decode sent theme: %v", err)
1155+ return err
1156+ }
1157+ themesMu.Lock()
1158+ themes = append(themes, theme)
1159+ themesMu.Unlock()
1160+ if theme.SchemeKnown && theme.PaletteKnown == 0 {
1161+ clearOnce.Do(func() { close(clearedPalette) })
1162+ }
1163+ return nil
1164+ }).Times(2)
1165+
1166+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
1167+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
1168+ closed := make(chan struct{})
1169+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1170+ select {
1171+ case <-closed:
1172+ return ports.Frame{}, io.EOF
1173+ default:
1174+ }
1175+ select {
1176+ case <-clearedPalette:
1177+ close(closed)
1178+ return detached, nil
1179+ default:
1180+ return welcome, nil
1181+ }
1182+ }).Maybe()
1183+ tr.EXPECT().Close().Return(nil).Once()
1184+
1185+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1186+ require.Equal(t, int32(1), restoreCount.Load())
1187+
1188+ themesMu.Lock()
1189+ defer themesMu.Unlock()
1190+ require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent")
1191+ require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)
1192+ require.True(t, themes[0].HasBackground)
1193+ require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries")
1194+ require.Equal(t, themes[0].Background, themes[1].Background)
1195+ require.True(t, themes[1].SchemeKnown)
1196+ require.True(t, themes[1].Light)
1197+}
A internal/usecase/client/theme_state_test.go +286 -0
@@ -0,0 +1,286 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) {
18+ state := &terminalThemeState{}
19+ state.update(func(theme *ports.Theme) {
20+ theme.HasForeground = true
21+ theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
22+ theme.HasBackground = true
23+ theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
24+ theme.SchemeKnown = true
25+ theme.Light = true
26+ theme.PaletteKnown = 1<<1 | 1<<14
27+ theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9}
28+ theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12}
29+ })
30+
31+ got := state.clearPalette()
32+ require.True(t, got.HasForeground)
33+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground)
34+ require.True(t, got.HasBackground)
35+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background)
36+ require.True(t, got.SchemeKnown)
37+ require.True(t, got.Light)
38+ require.Zero(t, got.PaletteKnown)
39+ require.Equal(t, [16]renderer.RGB{}, got.Palette)
40+
41+ _, reported := state.reportedTheme()
42+ require.True(t, reported, "foreground/background remain usable after palette invalidation")
43+}
44+
45+type queryBoundaryReader struct {
46+ first, second []byte
47+ readSecond chan struct{}
48+ allowSecond, close <-chan struct{}
49+ reads int
50+}
51+
52+func (r *queryBoundaryReader) Read(p []byte) (int, error) {
53+ switch r.reads {
54+ case 0:
55+ r.reads++
56+ return copy(p, r.first), nil
57+ case 1:
58+ select {
59+ case r.readSecond <- struct{}{}:
60+ case <-r.close:
61+ return 0, io.EOF
62+ }
63+ select {
64+ case <-r.allowSecond:
65+ r.reads++
66+ return copy(p, r.second), nil
67+ case <-r.close:
68+ return 0, io.EOF
69+ }
70+ default:
71+ <-r.close
72+ return 0, io.EOF
73+ }
74+}
75+
76+func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) {
77+ state := &terminalThemeState{}
78+ ctx, cancel := context.WithCancel(context.Background())
79+ defer cancel()
80+ out := make(chan ports.Frame, 3)
81+ pump := stdinPump{
82+ ctx: ctx,
83+ cancel: cancel,
84+ in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")),
85+ out: out,
86+ themeState: state,
87+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
88+ }
89+
90+ pump.run()
91+
92+ first := <-out
93+ require.Equal(t, ports.MsgTheme, first.Type)
94+ theme, err := ports.UnmarshalTheme(first.Payload)
95+ require.NoError(t, err)
96+ require.True(t, theme.HasBackground)
97+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background)
98+ require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown)
99+ require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1])
100+ require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14])
101+
102+ second := <-out
103+ require.Equal(t, ports.MsgDetach, second.Type)
104+ select {
105+ case extra := <-out:
106+ t.Fatalf("got extra frame after one input chunk: %v", extra.Type)
107+ default:
108+ }
109+}
110+
111+func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) {
112+ ctx, cancel := context.WithCancel(context.Background())
113+ defer cancel()
114+ allowSecond := make(chan struct{})
115+ closeReader := make(chan struct{})
116+ reader := &queryBoundaryReader{
117+ first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"),
118+ second: []byte("\x1b]4;2;#445566\a"),
119+ readSecond: make(chan struct{}, 1),
120+ allowSecond: allowSecond,
121+ close: closeReader,
122+ }
123+ queryStarted := make(chan struct{})
124+ allowQuery := make(chan struct{})
125+ out := make(chan ports.Frame, 3)
126+ pump := stdinPump{
127+ ctx: ctx,
128+ cancel: cancel,
129+ in: reader,
130+ out: out,
131+ themeState: &terminalThemeState{},
132+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
133+ requestColors: func() (colorQueryRequest, bool) {
134+ close(queryStarted)
135+ select {
136+ case <-allowQuery:
137+ return colorQueryRequest{}, true
138+ case <-ctx.Done():
139+ return colorQueryRequest{}, false
140+ }
141+ },
142+ }
143+ pumpDone := make(chan struct{})
144+ go func() {
145+ pump.run()
146+ close(pumpDone)
147+ }()
148+
149+ select {
150+ case <-queryStarted:
151+ case <-time.After(time.Second):
152+ t.Fatal("palette re-query was not requested")
153+ }
154+ select {
155+ case <-reader.readSecond:
156+ t.Fatal("stdin pump started the next read before the query acknowledgement")
157+ default:
158+ }
159+
160+ close(allowQuery)
161+ select {
162+ case <-reader.readSecond:
163+ case <-time.After(time.Second):
164+ t.Fatal("stdin pump did not resume after the query acknowledgement")
165+ }
166+
167+ cancel()
168+ close(closeReader)
169+ select {
170+ case <-pumpDone:
171+ case <-time.After(time.Second):
172+ t.Fatal("stdin pump did not unwind after cancellation")
173+ }
174+}
175+
176+type chunksReader struct {
177+ chunks [][]byte
178+ next int
179+}
180+
181+func (r *chunksReader) Read(p []byte) (int, error) {
182+ if r.next == len(r.chunks) {
183+ return 0, io.EOF
184+ }
185+ chunk := r.chunks[r.next]
186+ r.next++
187+ return copy(p, chunk), nil
188+}
189+
190+func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) {
191+ ctx, cancel := context.WithCancel(context.Background())
192+ defer cancel()
193+ requests := 0
194+ pump := stdinPump{
195+ ctx: ctx,
196+ cancel: cancel,
197+ in: &chunksReader{chunks: [][]byte{
198+ []byte("\x1b[?997;2n"),
199+ []byte("\x1b[?997;1n"),
200+ }},
201+ out: make(chan ports.Frame, 3),
202+ themeState: &terminalThemeState{},
203+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
204+ requestColors: func() (colorQueryRequest, bool) {
205+ requests++
206+ return colorQueryRequest{}, true
207+ },
208+ }
209+
210+ pump.run()
211+ require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives")
212+}
213+
214+func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) {
215+ for _, status := range []byte{'0', '1', '2', '3', '4'} {
216+ t.Run(string(status), func(t *testing.T) {
217+ response := []byte("\x1b[?2031;" + string(status) + "$y")
218+ var got []byte
219+ boundaries := 0
220+ drain := paletteDrain{}
221+ drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) {
222+ got = append(got, data...)
223+ }, func() { boundaries++ })
224+ drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) {
225+ got = append(got, data...)
226+ }, func() { boundaries++ })
227+
228+ require.Equal(t, []byte("xy"), got)
229+ require.Equal(t, 1, boundaries)
230+ })
231+ }
232+}
233+
234+func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) {
235+ var got []byte
236+ boundaries := 0
237+ drain := paletteDrain{}
238+ emit := func(data []byte) { got = append(got, data...) }
239+ drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ })
240+ drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ })
241+ drain.flush(emit)
242+
243+ require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got)
244+ require.Zero(t, boundaries)
245+}
246+
247+func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {
248+ ctx, cancel := context.WithCancel(context.Background())
249+ closeReader := make(chan struct{})
250+ reader := &queryBoundaryReader{
251+ first: []byte("\x1b[?997;2n"),
252+ close: closeReader,
253+ }
254+ requestStarted := make(chan struct{})
255+ pump := stdinPump{
256+ ctx: ctx,
257+ cancel: cancel,
258+ in: reader,
259+ out: make(chan ports.Frame, 2),
260+ themeState: &terminalThemeState{},
261+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
262+ requestColors: func() (colorQueryRequest, bool) {
263+ close(requestStarted)
264+ <-ctx.Done()
265+ return colorQueryRequest{}, false
266+ },
267+ }
268+ pumpDone := make(chan struct{})
269+ go func() {
270+ pump.run()
271+ close(pumpDone)
272+ }()
273+
274+ select {
275+ case <-requestStarted:
276+ case <-time.After(time.Second):
277+ t.Fatal("palette re-query was not requested")
278+ }
279+ cancel()
280+ select {
281+ case <-pumpDone:
282+ case <-time.After(time.Second):
283+ t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement")
284+ }
285+ close(closeReader)
286+}
@@ -0,0 +1,286 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) {
18+ state := &terminalThemeState{}
19+ state.update(func(theme *ports.Theme) {
20+ theme.HasForeground = true
21+ theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
22+ theme.HasBackground = true
23+ theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
24+ theme.SchemeKnown = true
25+ theme.Light = true
26+ theme.PaletteKnown = 1<<1 | 1<<14
27+ theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9}
28+ theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12}
29+ })
30+
31+ got := state.clearPalette()
32+ require.True(t, got.HasForeground)
33+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground)
34+ require.True(t, got.HasBackground)
35+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background)
36+ require.True(t, got.SchemeKnown)
37+ require.True(t, got.Light)
38+ require.Zero(t, got.PaletteKnown)
39+ require.Equal(t, [16]renderer.RGB{}, got.Palette)
40+
41+ _, reported := state.reportedTheme()
42+ require.True(t, reported, "foreground/background remain usable after palette invalidation")
43+}
44+
45+type queryBoundaryReader struct {
46+ first, second []byte
47+ readSecond chan struct{}
48+ allowSecond, close <-chan struct{}
49+ reads int
50+}
51+
52+func (r *queryBoundaryReader) Read(p []byte) (int, error) {
53+ switch r.reads {
54+ case 0:
55+ r.reads++
56+ return copy(p, r.first), nil
57+ case 1:
58+ select {
59+ case r.readSecond <- struct{}{}:
60+ case <-r.close:
61+ return 0, io.EOF
62+ }
63+ select {
64+ case <-r.allowSecond:
65+ r.reads++
66+ return copy(p, r.second), nil
67+ case <-r.close:
68+ return 0, io.EOF
69+ }
70+ default:
71+ <-r.close
72+ return 0, io.EOF
73+ }
74+}
75+
76+func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) {
77+ state := &terminalThemeState{}
78+ ctx, cancel := context.WithCancel(context.Background())
79+ defer cancel()
80+ out := make(chan ports.Frame, 3)
81+ pump := stdinPump{
82+ ctx: ctx,
83+ cancel: cancel,
84+ in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")),
85+ out: out,
86+ themeState: state,
87+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
88+ }
89+
90+ pump.run()
91+
92+ first := <-out
93+ require.Equal(t, ports.MsgTheme, first.Type)
94+ theme, err := ports.UnmarshalTheme(first.Payload)
95+ require.NoError(t, err)
96+ require.True(t, theme.HasBackground)
97+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background)
98+ require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown)
99+ require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1])
100+ require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14])
101+
102+ second := <-out
103+ require.Equal(t, ports.MsgDetach, second.Type)
104+ select {
105+ case extra := <-out:
106+ t.Fatalf("got extra frame after one input chunk: %v", extra.Type)
107+ default:
108+ }
109+}
110+
111+func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) {
112+ ctx, cancel := context.WithCancel(context.Background())
113+ defer cancel()
114+ allowSecond := make(chan struct{})
115+ closeReader := make(chan struct{})
116+ reader := &queryBoundaryReader{
117+ first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"),
118+ second: []byte("\x1b]4;2;#445566\a"),
119+ readSecond: make(chan struct{}, 1),
120+ allowSecond: allowSecond,
121+ close: closeReader,
122+ }
123+ queryStarted := make(chan struct{})
124+ allowQuery := make(chan struct{})
125+ out := make(chan ports.Frame, 3)
126+ pump := stdinPump{
127+ ctx: ctx,
128+ cancel: cancel,
129+ in: reader,
130+ out: out,
131+ themeState: &terminalThemeState{},
132+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
133+ requestColors: func() (colorQueryRequest, bool) {
134+ close(queryStarted)
135+ select {
136+ case <-allowQuery:
137+ return colorQueryRequest{}, true
138+ case <-ctx.Done():
139+ return colorQueryRequest{}, false
140+ }
141+ },
142+ }
143+ pumpDone := make(chan struct{})
144+ go func() {
145+ pump.run()
146+ close(pumpDone)
147+ }()
148+
149+ select {
150+ case <-queryStarted:
151+ case <-time.After(time.Second):
152+ t.Fatal("palette re-query was not requested")
153+ }
154+ select {
155+ case <-reader.readSecond:
156+ t.Fatal("stdin pump started the next read before the query acknowledgement")
157+ default:
158+ }
159+
160+ close(allowQuery)
161+ select {
162+ case <-reader.readSecond:
163+ case <-time.After(time.Second):
164+ t.Fatal("stdin pump did not resume after the query acknowledgement")
165+ }
166+
167+ cancel()
168+ close(closeReader)
169+ select {
170+ case <-pumpDone:
171+ case <-time.After(time.Second):
172+ t.Fatal("stdin pump did not unwind after cancellation")
173+ }
174+}
175+
176+type chunksReader struct {
177+ chunks [][]byte
178+ next int
179+}
180+
181+func (r *chunksReader) Read(p []byte) (int, error) {
182+ if r.next == len(r.chunks) {
183+ return 0, io.EOF
184+ }
185+ chunk := r.chunks[r.next]
186+ r.next++
187+ return copy(p, chunk), nil
188+}
189+
190+func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) {
191+ ctx, cancel := context.WithCancel(context.Background())
192+ defer cancel()
193+ requests := 0
194+ pump := stdinPump{
195+ ctx: ctx,
196+ cancel: cancel,
197+ in: &chunksReader{chunks: [][]byte{
198+ []byte("\x1b[?997;2n"),
199+ []byte("\x1b[?997;1n"),
200+ }},
201+ out: make(chan ports.Frame, 3),
202+ themeState: &terminalThemeState{},
203+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
204+ requestColors: func() (colorQueryRequest, bool) {
205+ requests++
206+ return colorQueryRequest{}, true
207+ },
208+ }
209+
210+ pump.run()
211+ require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives")
212+}
213+
214+func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) {
215+ for _, status := range []byte{'0', '1', '2', '3', '4'} {
216+ t.Run(string(status), func(t *testing.T) {
217+ response := []byte("\x1b[?2031;" + string(status) + "$y")
218+ var got []byte
219+ boundaries := 0
220+ drain := paletteDrain{}
221+ drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) {
222+ got = append(got, data...)
223+ }, func() { boundaries++ })
224+ drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) {
225+ got = append(got, data...)
226+ }, func() { boundaries++ })
227+
228+ require.Equal(t, []byte("xy"), got)
229+ require.Equal(t, 1, boundaries)
230+ })
231+ }
232+}
233+
234+func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) {
235+ var got []byte
236+ boundaries := 0
237+ drain := paletteDrain{}
238+ emit := func(data []byte) { got = append(got, data...) }
239+ drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ })
240+ drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ })
241+ drain.flush(emit)
242+
243+ require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got)
244+ require.Zero(t, boundaries)
245+}
246+
247+func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {
248+ ctx, cancel := context.WithCancel(context.Background())
249+ closeReader := make(chan struct{})
250+ reader := &queryBoundaryReader{
251+ first: []byte("\x1b[?997;2n"),
252+ close: closeReader,
253+ }
254+ requestStarted := make(chan struct{})
255+ pump := stdinPump{
256+ ctx: ctx,
257+ cancel: cancel,
258+ in: reader,
259+ out: make(chan ports.Frame, 2),
260+ themeState: &terminalThemeState{},
261+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
262+ requestColors: func() (colorQueryRequest, bool) {
263+ close(requestStarted)
264+ <-ctx.Done()
265+ return colorQueryRequest{}, false
266+ },
267+ }
268+ pumpDone := make(chan struct{})
269+ go func() {
270+ pump.run()
271+ close(pumpDone)
272+ }()
273+
274+ select {
275+ case <-requestStarted:
276+ case <-time.After(time.Second):
277+ t.Fatal("palette re-query was not requested")
278+ }
279+ cancel()
280+ select {
281+ case <-pumpDone:
282+ case <-time.After(time.Second):
283+ t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement")
284+ }
285+ close(closeReader)
286+}
M internal/usecase/daemon/client.go +10 -8
@@ -698,14 +698,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
698698
699699 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
700700 clientTheme := themeui.Theme{
701- Foreground: msg.Foreground,
702- Background: msg.Background,
703- HasFG: msg.HasForeground,
704- HasBG: msg.HasBackground,
705- TrueColor: msg.TrueColor,
706- Known: msg.HasForeground && msg.HasBackground,
707- SchemeKnown: msg.SchemeKnown,
708- Light: msg.Light,
701+ Foreground: msg.Foreground,
702+ Background: msg.Background,
703+ Palette: msg.Palette,
704+ PaletteKnown: msg.PaletteKnown,
705+ HasFG: msg.HasForeground,
706+ HasBG: msg.HasBackground,
707+ TrueColor: msg.TrueColor,
708+ Known: msg.HasForeground && msg.HasBackground,
709+ SchemeKnown: msg.SchemeKnown,
710+ Light: msg.Light,
709711 }
710712 ac.setClientTheme(clientTheme)
711713 t := d.effectiveTheme(clientTheme)
@@ -698,14 +698,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
698 698
699 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {699 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
700 clientTheme := themeui.Theme{700 clientTheme := themeui.Theme{
701- Foreground: msg.Foreground,701+ Foreground: msg.Foreground,
702- Background: msg.Background,702+ Background: msg.Background,
703- HasFG: msg.HasForeground,703+ Palette: msg.Palette,
704- HasBG: msg.HasBackground,704+ PaletteKnown: msg.PaletteKnown,
705- TrueColor: msg.TrueColor,705+ HasFG: msg.HasForeground,
706- Known: msg.HasForeground && msg.HasBackground,706+ HasBG: msg.HasBackground,
707- SchemeKnown: msg.SchemeKnown,707+ TrueColor: msg.TrueColor,
708- Light: msg.Light,708+ Known: msg.HasForeground && msg.HasBackground,
709+ SchemeKnown: msg.SchemeKnown,
710+ Light: msg.Light,
709 }711 }
710 ac.setClientTheme(clientTheme)712 ac.setClientTheme(clientTheme)
711 t := d.effectiveTheme(clientTheme)713 t := d.effectiveTheme(clientTheme)
M internal/usecase/daemon/config.go +24 -2
@@ -16,6 +16,26 @@ import (
1616
1717 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
1818
19+// themeConfigSnapshot is published as one immutable value. Its zero value is
20+// the default theme configuration: automatic mode with palette inheritance on.
21+type themeConfigSnapshot struct {
22+ mode domain.ThemeMode
23+ paletteOff bool
24+}
25+
26+func (d *Daemon) storeThemeConfig(cfg domain.Config) {
27+ snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette}
28+ d.themeConfig.Store(&snapshot)
29+}
30+
31+func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
32+ snapshot := d.themeConfig.Load()
33+ if snapshot == nil {
34+ return themeConfigSnapshot{}
35+ }
36+ return *snapshot
37+}
38+
1939 // ApplyConfig validates and atomically swaps daemon runtime configuration.
2040 func (d *Daemon) ApplyConfig(cfg domain.Config) {
2141 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
@@ -35,7 +55,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
3555 d.paletteConfig.Store(&palette)
3656 tabs := cfg.Tabs
3757 d.tabsConfig.Store(&tabs)
38- d.themeMode.Store(uint32(cfg.Theme))
58+ d.storeThemeConfig(cfg)
3959 if d.barScripts != nil {
4060 d.barScripts.mu.Lock()
4161 d.barScripts.initLocked()
@@ -240,12 +260,14 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
240260 }
241261
242262 func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
243- switch domain.ThemeMode(d.themeMode.Load()) {
263+ config := d.currentThemeConfig()
264+ switch config.mode {
244265 case domain.ThemeDark:
245266 return theme.BuiltinDark
246267 case domain.ThemeLight:
247268 return theme.BuiltinLight
248269 default:
270+ clientTheme.UsePalette = !config.paletteOff
249271 return clientTheme
250272 }
251273 }
@@ -16,6 +16,26 @@ import (
16 16
17 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)17 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
18 18
19+// themeConfigSnapshot is published as one immutable value. Its zero value is
20+// the default theme configuration: automatic mode with palette inheritance on.
21+type themeConfigSnapshot struct {
22+ mode domain.ThemeMode
23+ paletteOff bool
24+}
25+
26+func (d *Daemon) storeThemeConfig(cfg domain.Config) {
27+ snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette}
28+ d.themeConfig.Store(&snapshot)
29+}
30+
31+func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
32+ snapshot := d.themeConfig.Load()
33+ if snapshot == nil {
34+ return themeConfigSnapshot{}
35+ }
36+ return *snapshot
37+}
38+
19 // ApplyConfig validates and atomically swaps daemon runtime configuration.39 // ApplyConfig validates and atomically swaps daemon runtime configuration.
20 func (d *Daemon) ApplyConfig(cfg domain.Config) {40 func (d *Daemon) ApplyConfig(cfg domain.Config) {
21 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)41 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
@@ -35,7 +55,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
35 d.paletteConfig.Store(&palette)55 d.paletteConfig.Store(&palette)
36 tabs := cfg.Tabs56 tabs := cfg.Tabs
37 d.tabsConfig.Store(&tabs)57 d.tabsConfig.Store(&tabs)
38- d.themeMode.Store(uint32(cfg.Theme))58+ d.storeThemeConfig(cfg)
39 if d.barScripts != nil {59 if d.barScripts != nil {
40 d.barScripts.mu.Lock()60 d.barScripts.mu.Lock()
41 d.barScripts.initLocked()61 d.barScripts.initLocked()
@@ -240,12 +260,14 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
240 }260 }
241 261
242 func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {262 func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
243- switch domain.ThemeMode(d.themeMode.Load()) {263+ config := d.currentThemeConfig()
264+ switch config.mode {
244 case domain.ThemeDark:265 case domain.ThemeDark:
245 return theme.BuiltinDark266 return theme.BuiltinDark
246 case domain.ThemeLight:267 case domain.ThemeLight:
247 return theme.BuiltinLight268 return theme.BuiltinLight
248 default:269 default:
270+ clientTheme.UsePalette = !config.paletteOff
249 return clientTheme271 return clientTheme
250 }272 }
251 }273 }
M internal/usecase/daemon/config_test.go +197 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "sync"
45 "testing"
56
67 "github.com/stretchr/testify/require"
@@ -8,6 +9,8 @@ import (
89 "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/keys"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/pkg/renderer"
1114 )
1215
1316 type captureKeyHandler struct {
@@ -50,6 +53,200 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
5053 require.Equal(t, "NT", cmd.Code)
5154 }
5255
56+func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
57+ p, release := newBlockingPTY(t)
58+ defer release()
59+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
60+
61+ clientPalette := [16]renderer.RGB{}
62+ clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3}
63+ clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6}
64+ d.applyTheme(sess, ac, ports.Theme{
65+ HasForeground: true,
66+ Foreground: renderer.RGB{R: 7, G: 8, B: 9},
67+ HasBackground: true,
68+ Background: renderer.RGB{R: 10, G: 11, B: 12},
69+ Palette: clientPalette,
70+ PaletteKnown: 1<<1 | 1<<12,
71+ })
72+
73+ require.Equal(t, clientPalette, ac.getClientTheme().Palette)
74+ require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
75+ require.True(t, ac.getTheme().UsePalette)
76+
77+ cfg := domain.Defaults()
78+ cfg.ThemePalette = false
79+ d.ApplyConfig(cfg)
80+
81+ // ApplyConfig reuses the live client report and repaints without requiring
82+ // a reconnect; only palette inheritance changes.
83+ reloaded := ac.getTheme()
84+ require.Equal(t, clientPalette, reloaded.Palette)
85+ require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
86+ require.False(t, reloaded.UsePalette)
87+}
88+
89+func TestEffectiveThemePaletteGate(t *testing.T) {
90+ clientPalette := [16]renderer.RGB{}
91+ clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3}
92+ clientTheme := themeui.Theme{
93+ Palette: clientPalette,
94+ PaletteKnown: 1 << 3,
95+ }
96+
97+ tests := []struct {
98+ name string
99+ config domain.Config
100+ want themeui.Theme
101+ usePalette bool
102+ }{
103+ {
104+ name: "auto inherits terminal palette by default",
105+ config: domain.Defaults(),
106+ want: clientTheme,
107+ usePalette: true,
108+ },
109+ {
110+ name: "auto disables terminal palette",
111+ config: func() domain.Config {
112+ cfg := domain.Defaults()
113+ cfg.ThemePalette = false
114+ return cfg
115+ }(),
116+ want: clientTheme,
117+ },
118+ {
119+ name: "forced dark uses palette free builtin",
120+ config: domain.Config{
121+ Theme: domain.ThemeDark,
122+ ThemePalette: true,
123+ },
124+ want: themeui.BuiltinDark,
125+ },
126+ {
127+ name: "forced dark ignores disabled palette inheritance",
128+ config: domain.Config{
129+ Theme: domain.ThemeDark,
130+ ThemePalette: false,
131+ },
132+ want: themeui.BuiltinDark,
133+ },
134+ {
135+ name: "forced light uses palette free builtin",
136+ config: domain.Config{
137+ Theme: domain.ThemeLight,
138+ ThemePalette: true,
139+ },
140+ want: themeui.BuiltinLight,
141+ },
142+ {
143+ name: "forced light ignores disabled palette inheritance",
144+ config: domain.Config{
145+ Theme: domain.ThemeLight,
146+ ThemePalette: false,
147+ },
148+ want: themeui.BuiltinLight,
149+ },
150+ }
151+
152+ for _, tt := range tests {
153+ t.Run(tt.name, func(t *testing.T) {
154+ d := newTestDaemon(t, nil, stubClock{})
155+ d.ApplyConfig(tt.config)
156+
157+ got := d.effectiveTheme(clientTheme)
158+ want := tt.want
159+ want.UsePalette = tt.usePalette
160+ require.Equal(t, want, got)
161+ })
162+ }
163+}
164+
165+func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
166+ d := newTestDaemon(t, nil, stubClock{})
167+
168+ // A nil atomic pointer is the zero/default snapshot: auto theme with
169+ // terminal palette inheritance enabled.
170+ require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig())
171+
172+ for _, tt := range []struct {
173+ name string
174+ mode domain.ThemeMode
175+ gate bool
176+ }{
177+ {name: "auto palette enabled", mode: domain.ThemeAuto, gate: true},
178+ {name: "auto palette disabled", mode: domain.ThemeAuto, gate: false},
179+ {name: "dark palette enabled", mode: domain.ThemeDark, gate: true},
180+ {name: "dark palette disabled", mode: domain.ThemeDark, gate: false},
181+ {name: "light palette enabled", mode: domain.ThemeLight, gate: true},
182+ {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
183+ } {
184+ t.Run(tt.name, func(t *testing.T) {
185+ d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
186+ require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
187+ })
188+ }
189+
190+ // Publish complementary configurations concurrently. Every load must be a
191+ // complete published snapshot, never mode from one update and gate from
192+ // another. Coordination establishes concurrent readers/writers without
193+ // timing sleeps.
194+ first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false}
195+ second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true}
196+ allowed := map[themeConfigSnapshot]struct{}{
197+ {mode: domain.ThemeAuto, paletteOff: true}: {},
198+ {mode: domain.ThemeDark, paletteOff: false}: {},
199+ }
200+ firstPublished := make(chan struct{})
201+ firstObserved := make(chan struct{})
202+ secondPublished := make(chan struct{})
203+ secondObserved := make(chan struct{})
204+ observed := make(chan themeConfigSnapshot, 2)
205+ invalid := make(chan themeConfigSnapshot, 1)
206+ var wg sync.WaitGroup
207+ wg.Add(2)
208+ go func() {
209+ defer wg.Done()
210+ d.storeThemeConfig(first)
211+ close(firstPublished)
212+ <-firstObserved
213+ d.storeThemeConfig(second)
214+ close(secondPublished)
215+ <-secondObserved
216+ for range 10_000 {
217+ d.storeThemeConfig(second)
218+ d.storeThemeConfig(first)
219+ }
220+ }()
221+ go func() {
222+ defer wg.Done()
223+ <-firstPublished
224+ observed <- d.currentThemeConfig()
225+ close(firstObserved)
226+ <-secondPublished
227+ observed <- d.currentThemeConfig()
228+ close(secondObserved)
229+ for range 20_000 {
230+ snapshot := d.currentThemeConfig()
231+ if _, ok := allowed[snapshot]; !ok {
232+ select {
233+ case invalid <- snapshot:
234+ default:
235+ }
236+ return
237+ }
238+ }
239+ }()
240+ wg.Wait()
241+ require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed)
242+ require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed)
243+ select {
244+ case snapshot := <-invalid:
245+ t.Fatalf("observed mixed theme configuration: %#v", snapshot)
246+ default:
247+ }
248+}
249+
53250 func TestApplyConfigPublishesCopyConfig(t *testing.T) {
54251 tests := []struct {
55252 name string
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
@@ -8,6 +9,8 @@ import (
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/keys"11 "github.com/bnema/vev/internal/usecase/keys"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/pkg/renderer"
11 )14 )
12 15
13 type captureKeyHandler struct {16 type captureKeyHandler struct {
@@ -50,6 +53,200 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
50 require.Equal(t, "NT", cmd.Code)53 require.Equal(t, "NT", cmd.Code)
51 }54 }
52 55
56+func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
57+ p, release := newBlockingPTY(t)
58+ defer release()
59+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
60+
61+ clientPalette := [16]renderer.RGB{}
62+ clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3}
63+ clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6}
64+ d.applyTheme(sess, ac, ports.Theme{
65+ HasForeground: true,
66+ Foreground: renderer.RGB{R: 7, G: 8, B: 9},
67+ HasBackground: true,
68+ Background: renderer.RGB{R: 10, G: 11, B: 12},
69+ Palette: clientPalette,
70+ PaletteKnown: 1<<1 | 1<<12,
71+ })
72+
73+ require.Equal(t, clientPalette, ac.getClientTheme().Palette)
74+ require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
75+ require.True(t, ac.getTheme().UsePalette)
76+
77+ cfg := domain.Defaults()
78+ cfg.ThemePalette = false
79+ d.ApplyConfig(cfg)
80+
81+ // ApplyConfig reuses the live client report and repaints without requiring
82+ // a reconnect; only palette inheritance changes.
83+ reloaded := ac.getTheme()
84+ require.Equal(t, clientPalette, reloaded.Palette)
85+ require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
86+ require.False(t, reloaded.UsePalette)
87+}
88+
89+func TestEffectiveThemePaletteGate(t *testing.T) {
90+ clientPalette := [16]renderer.RGB{}
91+ clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3}
92+ clientTheme := themeui.Theme{
93+ Palette: clientPalette,
94+ PaletteKnown: 1 << 3,
95+ }
96+
97+ tests := []struct {
98+ name string
99+ config domain.Config
100+ want themeui.Theme
101+ usePalette bool
102+ }{
103+ {
104+ name: "auto inherits terminal palette by default",
105+ config: domain.Defaults(),
106+ want: clientTheme,
107+ usePalette: true,
108+ },
109+ {
110+ name: "auto disables terminal palette",
111+ config: func() domain.Config {
112+ cfg := domain.Defaults()
113+ cfg.ThemePalette = false
114+ return cfg
115+ }(),
116+ want: clientTheme,
117+ },
118+ {
119+ name: "forced dark uses palette free builtin",
120+ config: domain.Config{
121+ Theme: domain.ThemeDark,
122+ ThemePalette: true,
123+ },
124+ want: themeui.BuiltinDark,
125+ },
126+ {
127+ name: "forced dark ignores disabled palette inheritance",
128+ config: domain.Config{
129+ Theme: domain.ThemeDark,
130+ ThemePalette: false,
131+ },
132+ want: themeui.BuiltinDark,
133+ },
134+ {
135+ name: "forced light uses palette free builtin",
136+ config: domain.Config{
137+ Theme: domain.ThemeLight,
138+ ThemePalette: true,
139+ },
140+ want: themeui.BuiltinLight,
141+ },
142+ {
143+ name: "forced light ignores disabled palette inheritance",
144+ config: domain.Config{
145+ Theme: domain.ThemeLight,
146+ ThemePalette: false,
147+ },
148+ want: themeui.BuiltinLight,
149+ },
150+ }
151+
152+ for _, tt := range tests {
153+ t.Run(tt.name, func(t *testing.T) {
154+ d := newTestDaemon(t, nil, stubClock{})
155+ d.ApplyConfig(tt.config)
156+
157+ got := d.effectiveTheme(clientTheme)
158+ want := tt.want
159+ want.UsePalette = tt.usePalette
160+ require.Equal(t, want, got)
161+ })
162+ }
163+}
164+
165+func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
166+ d := newTestDaemon(t, nil, stubClock{})
167+
168+ // A nil atomic pointer is the zero/default snapshot: auto theme with
169+ // terminal palette inheritance enabled.
170+ require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig())
171+
172+ for _, tt := range []struct {
173+ name string
174+ mode domain.ThemeMode
175+ gate bool
176+ }{
177+ {name: "auto palette enabled", mode: domain.ThemeAuto, gate: true},
178+ {name: "auto palette disabled", mode: domain.ThemeAuto, gate: false},
179+ {name: "dark palette enabled", mode: domain.ThemeDark, gate: true},
180+ {name: "dark palette disabled", mode: domain.ThemeDark, gate: false},
181+ {name: "light palette enabled", mode: domain.ThemeLight, gate: true},
182+ {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
183+ } {
184+ t.Run(tt.name, func(t *testing.T) {
185+ d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
186+ require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
187+ })
188+ }
189+
190+ // Publish complementary configurations concurrently. Every load must be a
191+ // complete published snapshot, never mode from one update and gate from
192+ // another. Coordination establishes concurrent readers/writers without
193+ // timing sleeps.
194+ first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false}
195+ second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true}
196+ allowed := map[themeConfigSnapshot]struct{}{
197+ {mode: domain.ThemeAuto, paletteOff: true}: {},
198+ {mode: domain.ThemeDark, paletteOff: false}: {},
199+ }
200+ firstPublished := make(chan struct{})
201+ firstObserved := make(chan struct{})
202+ secondPublished := make(chan struct{})
203+ secondObserved := make(chan struct{})
204+ observed := make(chan themeConfigSnapshot, 2)
205+ invalid := make(chan themeConfigSnapshot, 1)
206+ var wg sync.WaitGroup
207+ wg.Add(2)
208+ go func() {
209+ defer wg.Done()
210+ d.storeThemeConfig(first)
211+ close(firstPublished)
212+ <-firstObserved
213+ d.storeThemeConfig(second)
214+ close(secondPublished)
215+ <-secondObserved
216+ for range 10_000 {
217+ d.storeThemeConfig(second)
218+ d.storeThemeConfig(first)
219+ }
220+ }()
221+ go func() {
222+ defer wg.Done()
223+ <-firstPublished
224+ observed <- d.currentThemeConfig()
225+ close(firstObserved)
226+ <-secondPublished
227+ observed <- d.currentThemeConfig()
228+ close(secondObserved)
229+ for range 20_000 {
230+ snapshot := d.currentThemeConfig()
231+ if _, ok := allowed[snapshot]; !ok {
232+ select {
233+ case invalid <- snapshot:
234+ default:
235+ }
236+ return
237+ }
238+ }
239+ }()
240+ wg.Wait()
241+ require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed)
242+ require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed)
243+ select {
244+ case snapshot := <-invalid:
245+ t.Fatalf("observed mixed theme configuration: %#v", snapshot)
246+ default:
247+ }
248+}
249+
53 func TestApplyConfigPublishesCopyConfig(t *testing.T) {250 func TestApplyConfigPublishesCopyConfig(t *testing.T) {
54 tests := []struct {251 tests := []struct {
55 name string252 name string
M internal/usecase/daemon/copymode.go +3 -2
@@ -10,6 +10,7 @@ import (
1010 scopy "github.com/bnema/vev/internal/usecase/copy"
1111 "github.com/bnema/vev/internal/usecase/keys"
1212 "github.com/bnema/vev/internal/usecase/mouse"
13+ themeui "github.com/bnema/vev/internal/usecase/theme"
1314 "github.com/bnema/vev/internal/usecase/ui"
1415 "github.com/bnema/vev/internal/usecase/visualsearch"
1516 "github.com/bnema/vev/pkg/renderer"
@@ -415,8 +416,8 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
415416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
416417 return frame, nil
417418 }
418- styles := newThemeStyles(bars.theme)
419- copyFrame := mode.Render(styles.copyStatus, styles.selection)
419+ styles := themeui.NewStyles(bars.theme)
420+ copyFrame := mode.Render(styles.CopyStatus, styles.Selection)
420421 bodyRows := max(copyFrame.Height-1, 0)
421422 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
422423 dstX := max(target.X, 0)
@@ -10,6 +10,7 @@ import (
10 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
11 "github.com/bnema/vev/internal/usecase/keys"11 "github.com/bnema/vev/internal/usecase/keys"
12 "github.com/bnema/vev/internal/usecase/mouse"12 "github.com/bnema/vev/internal/usecase/mouse"
13+ themeui "github.com/bnema/vev/internal/usecase/theme"
13 "github.com/bnema/vev/internal/usecase/ui"14 "github.com/bnema/vev/internal/usecase/ui"
14 "github.com/bnema/vev/internal/usecase/visualsearch"15 "github.com/bnema/vev/internal/usecase/visualsearch"
15 "github.com/bnema/vev/pkg/renderer"16 "github.com/bnema/vev/pkg/renderer"
@@ -415,8 +416,8 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
415 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
416 return frame, nil417 return frame, nil
417 }418 }
418- styles := newThemeStyles(bars.theme)419+ styles := themeui.NewStyles(bars.theme)
419- copyFrame := mode.Render(styles.copyStatus, styles.selection)420+ copyFrame := mode.Render(styles.CopyStatus, styles.Selection)
420 bodyRows := max(copyFrame.Height-1, 0)421 bodyRows := max(copyFrame.Height-1, 0)
421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {422 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
422 dstX := max(target.X, 0)423 dstX := max(target.X, 0)
M internal/usecase/daemon/daemon.go +1 -1
@@ -144,7 +144,7 @@ type Daemon struct {
144144 copyConfig atomic.Pointer[domain.CopyConfig]
145145 paletteConfig atomic.Pointer[domain.PaletteConfig]
146146 tabsConfig atomic.Pointer[domain.TabsConfig]
147- themeMode atomic.Uint32
147+ themeConfig atomic.Pointer[themeConfigSnapshot]
148148 barScripts *barScriptState
149149 resumeParkGrace time.Duration
150150 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
@@ -144,7 +144,7 @@ type Daemon struct {
144 copyConfig atomic.Pointer[domain.CopyConfig]144 copyConfig atomic.Pointer[domain.CopyConfig]
145 paletteConfig atomic.Pointer[domain.PaletteConfig]145 paletteConfig atomic.Pointer[domain.PaletteConfig]
146 tabsConfig atomic.Pointer[domain.TabsConfig]146 tabsConfig atomic.Pointer[domain.TabsConfig]
147- themeMode atomic.Uint32147+ themeConfig atomic.Pointer[themeConfigSnapshot]
148 barScripts *barScriptState148 barScripts *barScriptState
149 resumeParkGrace time.Duration149 resumeParkGrace time.Duration
150 // tempDir overrides os.TempDir() for clipboard-image-transfer writes150 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
M internal/usecase/daemon/floating_render.go +1 -1
@@ -117,7 +117,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
117117 }
118118 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
119119 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
120- drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border)
120+ drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border)
121121 if full || popupChanged {
122122 return frame, []renderer.Damage{renderer.FullRedraw()}
123123 }
@@ -117,7 +117,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
117 }117 }
118 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry118 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
119 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration119 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
120- drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border)120+ drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border)
121 if full || popupChanged {121 if full || popupChanged {
122 return frame, []renderer.Damage{renderer.FullRedraw()}122 return frame, []renderer.Damage{renderer.FullRedraw()}
123 }123 }
M internal/usecase/daemon/modal_compose.go +3 -2
@@ -2,12 +2,13 @@ package daemon
22
33 import (
44 "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
56 "github.com/bnema/vev/internal/usecase/ui"
67 "github.com/bnema/vev/pkg/renderer"
78 )
89
9-func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
10- inner := modal.Composite(base, styles.border)
10+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11+ inner := modal.Composite(base, styles.Border)
1112 modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
1213 for y := range min(inner.Height, modalFrame.Height) {
1314 for x := range min(inner.Width, modalFrame.Width) {
@@ -2,12 +2,13 @@ package daemon
2 2
3 import (3 import (
4 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
5 "github.com/bnema/vev/internal/usecase/ui"6 "github.com/bnema/vev/internal/usecase/ui"
6 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
7 )8 )
8 9
9-func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {10+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
10- inner := modal.Composite(base, styles.border)11+ inner := modal.Composite(base, styles.Border)
11 modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)12 modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
12 for y := range min(inner.Height, modalFrame.Height) {13 for y := range min(inner.Height, modalFrame.Height) {
13 for x := range min(inner.Width, modalFrame.Width) {14 for x := range min(inner.Width, modalFrame.Width) {
M internal/usecase/daemon/palette.go +5 -4
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/vev/internal/usecase/layout"
1010 "github.com/bnema/vev/internal/usecase/palette"
1111 "github.com/bnema/vev/internal/usecase/picker"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
1213 "github.com/bnema/vev/internal/usecase/ui"
1314 "github.com/bnema/vev/pkg/renderer"
1415 )
@@ -496,10 +497,10 @@ func (e paletteExec) JumpRecentSession(rank int) error {
496497 return nil
497498 }
498499
499-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
500- styleSet := resolveThemeStyles(styles)
500+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
501+ styleSet := resolveStyles(styles)
501502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
502- return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
503- return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance})
503+ return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
504+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance})
504505 })
505506 }
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
10 "github.com/bnema/vev/internal/usecase/palette"10 "github.com/bnema/vev/internal/usecase/palette"
11 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
12 "github.com/bnema/vev/internal/usecase/ui"13 "github.com/bnema/vev/internal/usecase/ui"
13 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
14 )15 )
@@ -496,10 +497,10 @@ func (e paletteExec) JumpRecentSession(rank int) error {
496 return nil497 return nil
497 }498 }
498 499
499-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {500+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
500- styleSet := resolveThemeStyles(styles)501+ styleSet := resolveStyles(styles)
501 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
502- return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {503+ return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
503- return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance})504+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance})
504 })505 })
505 }506 }
M internal/usecase/daemon/render.go +2 -50
@@ -357,59 +357,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
357357 d.emitFrame(sess, ac, state, composed, &marks)
358358 }
359359
360-type themeStyles struct {
361- statusBar renderer.Style
362- accent renderer.Style
363- border renderer.Style
364- selection renderer.Style
365- copyStatus renderer.Style
366- paletteDesc renderer.Style
367-
368- // Top bar tab label segments: name (bold) and parenthesized pane-title
369- // (muted), one pair per base style. No-ops to statusBar/accent on
370- // non-truecolor themes.
371- tabName renderer.Style
372- tabNameActive renderer.Style
373- tabTitle renderer.Style
374- tabTitleActive renderer.Style
375-
376- // Session picker row segments. Detail reuses paletteDesc (both are
377- // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
378- pickerName renderer.Style
379- pickerSelectionName renderer.Style
380- pickerSelectionMuted renderer.Style
381- pickerSeparator renderer.Style
382-}
383-
384-func newThemeStyles(t themeui.Theme) themeStyles {
385- statusBar := themeui.StatusBarStyle(t)
386- accent := themeui.AccentStyle(t)
387- selection := themeui.SelectionStyle(t)
388- return themeStyles{
389- statusBar: statusBar,
390- accent: accent,
391- border: themeui.BorderStyle(t),
392- selection: selection,
393- copyStatus: selection,
394- paletteDesc: themeui.MutedTextStyle(t),
395-
396- tabName: themeui.EmphasisStyle(statusBar, t),
397- tabNameActive: themeui.EmphasisStyle(accent, t),
398- tabTitle: themeui.MutedVariantStyle(statusBar, t),
399- tabTitleActive: themeui.MutedVariantStyle(accent, t),
400-
401- pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
402- pickerSelectionName: themeui.EmphasisStyle(selection, t),
403- pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
404- pickerSeparator: themeui.MutedTextStyle(t),
405- }
406-}
407-
408-func resolveThemeStyles(styles []themeStyles) themeStyles {
360+func resolveStyles(styles []themeui.Styles) themeui.Styles {
409361 if len(styles) > 0 {
410362 return styles[0]
411363 }
412- return newThemeStyles(themeui.Theme{})
364+ return themeui.NewStyles(themeui.Theme{})
413365 }
414366
415367 type barCache struct {
@@ -357,59 +357,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
357 d.emitFrame(sess, ac, state, composed, &marks)357 d.emitFrame(sess, ac, state, composed, &marks)
358 }358 }
359 359
360-type themeStyles struct {360+func resolveStyles(styles []themeui.Styles) themeui.Styles {
361- statusBar renderer.Style
362- accent renderer.Style
363- border renderer.Style
364- selection renderer.Style
365- copyStatus renderer.Style
366- paletteDesc renderer.Style
367-
368- // Top bar tab label segments: name (bold) and parenthesized pane-title
369- // (muted), one pair per base style. No-ops to statusBar/accent on
370- // non-truecolor themes.
371- tabName renderer.Style
372- tabNameActive renderer.Style
373- tabTitle renderer.Style
374- tabTitleActive renderer.Style
375-
376- // Session picker row segments. Detail reuses paletteDesc (both are
377- // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
378- pickerName renderer.Style
379- pickerSelectionName renderer.Style
380- pickerSelectionMuted renderer.Style
381- pickerSeparator renderer.Style
382-}
383-
384-func newThemeStyles(t themeui.Theme) themeStyles {
385- statusBar := themeui.StatusBarStyle(t)
386- accent := themeui.AccentStyle(t)
387- selection := themeui.SelectionStyle(t)
388- return themeStyles{
389- statusBar: statusBar,
390- accent: accent,
391- border: themeui.BorderStyle(t),
392- selection: selection,
393- copyStatus: selection,
394- paletteDesc: themeui.MutedTextStyle(t),
395-
396- tabName: themeui.EmphasisStyle(statusBar, t),
397- tabNameActive: themeui.EmphasisStyle(accent, t),
398- tabTitle: themeui.MutedVariantStyle(statusBar, t),
399- tabTitleActive: themeui.MutedVariantStyle(accent, t),
400-
401- pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
402- pickerSelectionName: themeui.EmphasisStyle(selection, t),
403- pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
404- pickerSeparator: themeui.MutedTextStyle(t),
405- }
406-}
407-
408-func resolveThemeStyles(styles []themeStyles) themeStyles {
409 if len(styles) > 0 {361 if len(styles) > 0 {
410 return styles[0]362 return styles[0]
411 }363 }
412- return newThemeStyles(themeui.Theme{})364+ return themeui.NewStyles(themeui.Theme{})
413 }365 }
414 366
415 type barCache struct {367 type barCache struct {
M internal/usecase/daemon/render_pipeline.go +11 -11
@@ -77,14 +77,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
7777 copy(frame.Row(y), in.frame.Row(y))
7878 }
7979 }
80- styles := newThemeStyles(state.theme)
80+ styles := themeui.NewStyles(state.theme)
8181 defaultDimmer := themeui.NewDimmer(state.theme)
8282 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
8383 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
8484 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
8585 content := domain.Rect{Y: 1, Width: width, Height: rows}
8686 if state.layout.valid && state.layout.root != nil {
87- drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.border))
87+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))
8888 }
8989
9090 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
@@ -157,10 +157,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157157 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
158158 }
159159
160-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) {
161- style := styles.border
160+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) {
161+ style := styles.Border
162162 if focused {
163- style = styles.statusBar
163+ style = styles.StatusBar
164164 } else {
165165 style = dimmer.Dim(style)
166166 }
@@ -191,15 +191,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
191191 if snap.copyPane != nil {
192192 o.copyPaneID = snap.copyPane.id
193193 }
194- styles := newThemeStyles(state.theme)
194+ styles := themeui.NewStyles(state.theme)
195195 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
196196 if snap.copySearchModel != nil {
197197 o.copySearch.modal = copySearchModal
198- o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection)
198+ o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection)
199199 }
200200 if snap.pickerActive && snap.pickerModel != nil {
201201 o.picker.modal = pickerModal
202- renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator}
202+ renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator}
203203 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
204204 }
205205 if snap.paletteActive && snap.paletteModel != nil {
@@ -209,11 +209,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
209209 guidance = snap.paletteHints.Feedback
210210 }
211211 o.paletteGuidance = snap.paletteFeedback
212- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})
212+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})
213213 }
214214 if snap.promptActive && snap.promptModel != nil {
215215 o.prompt.modal = promptModalFor(snap.promptModel.Title())
216- o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent)
216+ o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent)
217217 }
218218 state.cursor.hiddenByOverlay = o.active()
219219 }
@@ -248,7 +248,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
248248 if modal.inner.Width == 0 && modal.inner.Height == 0 {
249249 continue
250250 }
251- inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border)
251+ inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border)
252252 for y := range min(inner.Height, modal.inner.Height) {
253253 copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
254254 }
@@ -77,14 +77,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
77 copy(frame.Row(y), in.frame.Row(y))77 copy(frame.Row(y), in.frame.Row(y))
78 }78 }
79 }79 }
80- styles := newThemeStyles(state.theme)80+ styles := themeui.NewStyles(state.theme)
81 defaultDimmer := themeui.NewDimmer(state.theme)81 defaultDimmer := themeui.NewDimmer(state.theme)
82 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))82 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
83 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)83 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
84 drawStatusBarState(frame.Row(rows+1), state.bars, styles)84 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
85 content := domain.Rect{Y: 1, Width: width, Height: rows}85 content := domain.Rect{Y: 1, Width: width, Height: rows}
86 if state.layout.valid && state.layout.root != nil {86 if state.layout.valid && state.layout.root != nil {
87- drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.border))87+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))
88 }88 }
89 89
90 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible90 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
@@ -157,10 +157,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}157 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
158 }158 }
159 159
160-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) {160+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) {
161- style := styles.border161+ style := styles.Border
162 if focused {162 if focused {
163- style = styles.statusBar163+ style = styles.StatusBar
164 } else {164 } else {
165 style = dimmer.Dim(style)165 style = dimmer.Dim(style)
166 }166 }
@@ -191,15 +191,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
191 if snap.copyPane != nil {191 if snap.copyPane != nil {
192 o.copyPaneID = snap.copyPane.id192 o.copyPaneID = snap.copyPane.id
193 }193 }
194- styles := newThemeStyles(state.theme)194+ styles := themeui.NewStyles(state.theme)
195 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}195 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
196 if snap.copySearchModel != nil {196 if snap.copySearchModel != nil {
197 o.copySearch.modal = copySearchModal197 o.copySearch.modal = copySearchModal
198- o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection)198+ o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection)
199 }199 }
200 if snap.pickerActive && snap.pickerModel != nil {200 if snap.pickerActive && snap.pickerModel != nil {
201 o.picker.modal = pickerModal201 o.picker.modal = pickerModal
202- renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator}202+ renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator}
203 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)203 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
204 }204 }
205 if snap.paletteActive && snap.paletteModel != nil {205 if snap.paletteActive && snap.paletteModel != nil {
@@ -209,11 +209,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
209 guidance = snap.paletteHints.Feedback209 guidance = snap.paletteHints.Feedback
210 }210 }
211 o.paletteGuidance = snap.paletteFeedback211 o.paletteGuidance = snap.paletteFeedback
212- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})212+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})
213 }213 }
214 if snap.promptActive && snap.promptModel != nil {214 if snap.promptActive && snap.promptModel != nil {
215 o.prompt.modal = promptModalFor(snap.promptModel.Title())215 o.prompt.modal = promptModalFor(snap.promptModel.Title())
216- o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent)216+ o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent)
217 }217 }
218 state.cursor.hiddenByOverlay = o.active()218 state.cursor.hiddenByOverlay = o.active()
219 }219 }
@@ -248,7 +248,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
248 if modal.inner.Width == 0 && modal.inner.Height == 0 {248 if modal.inner.Width == 0 && modal.inner.Height == 0 {
249 continue249 continue
250 }250 }
251- inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border)251+ inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border)
252 for y := range min(inner.Height, modal.inner.Height) {252 for y := range min(inner.Height, modal.inner.Height) {
253 copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])253 copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
254 }254 }
M internal/usecase/daemon/status.go +17 -30
@@ -41,28 +41,21 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
4141 distance = -distance
4242 }
4343 intensity := 1 - float64(distance)/float64(peak)
44- style := base
45- style.Bold = true
46- if base.HasForegroundRGB && base.HasBackgroundRGB {
47- style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
48- } else {
49- style.Foreground = 244 + int(intensity*11)
50- }
51- return style, true
44+ return themeui.PulseColor(base, intensity), true
5245 }
5346
54-func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {
47+func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
5548 clearStatusRow(row)
5649 x := 0
5750 labels := fitTabLabels(status.tabs, len(row), topRight)
5851 for i, w := range status.tabs {
59- baseStyle := styles.statusBar
60- nameStyle := styles.tabName
61- titleStyle := styles.tabTitle
52+ baseStyle := styles.StatusBar
53+ nameStyle := styles.TabName
54+ titleStyle := styles.TabTitle
6255 if w.active {
63- baseStyle = styles.accent
64- nameStyle = styles.tabNameActive
65- titleStyle = styles.tabTitleActive
56+ baseStyle = styles.Accent
57+ nameStyle = styles.TabNameActive
58+ titleStyle = styles.TabTitleActive
6659 }
6760 label := labels[i]
6861 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
@@ -73,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
7366 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
7467 writeStatusText(row, &x, " ", baseStyle)
7568 }
76- drawRightPlainText(row, topRight, x, styles.statusBar)
69+ drawRightPlainText(row, topRight, x, styles.StatusBar)
7770 }
7871
79-func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
72+func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
8073 clearStatusRow(row)
8174 x := 0
8275 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
83- writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
76+ writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent)
8477 if state.rankedRecent != nil {
8578 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
86- style := styles.statusBar // contextual ranks deliberately do not fade.
79+ style := styles.StatusBar // contextual ranks deliberately do not fade.
8780 if sess.selected {
88- style = styles.accent
81+ style = styles.Accent
8982 }
9083 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
9184 }
9285 } else {
9386 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
9487 for i, sess := range fittedMRU {
95- style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
88+ style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU))
9689 drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)
9790 }
9891 }
99- drawRightPlainText(row, rightText, x, styles.statusBar)
92+ drawRightPlainText(row, rightText, x, styles.StatusBar)
10093 }
10194
10295 type rankedRecent struct {
@@ -170,7 +163,7 @@ type barState struct {
170163 attentionFrame int
171164 // theme is the client's terminal theme, if reported. Its zero value
172165 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
173- // the pre-theme fallback styles (see newThemeStyles / theme.usable).
166+ // the pre-theme fallback styles (see themeui.NewStyles / theme.usable).
174167 theme themeui.Theme
175168 }
176169
@@ -434,13 +427,7 @@ func mruFutureRightReserve(rowLen int) int {
434427 }
435428
436429 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
437- if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
438- return base
439- }
440- amount := (float64(i) / float64(count-1)) * 0.6
441- base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
442- base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount)
443- return base
430+ return themeui.MRUFade(base, t, i, count)
444431 }
445432
446433 // writeBell draws the attention glyph on its visible beat, or a plain cell in
@@ -41,28 +41,21 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
41 distance = -distance41 distance = -distance
42 }42 }
43 intensity := 1 - float64(distance)/float64(peak)43 intensity := 1 - float64(distance)/float64(peak)
44- style := base44+ return themeui.PulseColor(base, intensity), true
45- style.Bold = true
46- if base.HasForegroundRGB && base.HasBackgroundRGB {
47- style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
48- } else {
49- style.Foreground = 244 + int(intensity*11)
50- }
51- return style, true
52 }45 }
53 46
54-func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {47+func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
55 clearStatusRow(row)48 clearStatusRow(row)
56 x := 049 x := 0
57 labels := fitTabLabels(status.tabs, len(row), topRight)50 labels := fitTabLabels(status.tabs, len(row), topRight)
58 for i, w := range status.tabs {51 for i, w := range status.tabs {
59- baseStyle := styles.statusBar52+ baseStyle := styles.StatusBar
60- nameStyle := styles.tabName53+ nameStyle := styles.TabName
61- titleStyle := styles.tabTitle54+ titleStyle := styles.TabTitle
62 if w.active {55 if w.active {
63- baseStyle = styles.accent56+ baseStyle = styles.Accent
64- nameStyle = styles.tabNameActive57+ nameStyle = styles.TabNameActive
65- titleStyle = styles.tabTitleActive58+ titleStyle = styles.TabTitleActive
66 }59 }
67 label := labels[i]60 label := labels[i]
68 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)61 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
@@ -73,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
73 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)66 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
74 writeStatusText(row, &x, " ", baseStyle)67 writeStatusText(row, &x, " ", baseStyle)
75 }68 }
76- drawRightPlainText(row, topRight, x, styles.statusBar)69+ drawRightPlainText(row, topRight, x, styles.StatusBar)
77 }70 }
78 71
79-func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {72+func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
80 clearStatusRow(row)73 clearStatusRow(row)
81 x := 074 x := 0
82 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)75 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
83- writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)76+ writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent)
84 if state.rankedRecent != nil {77 if state.rankedRecent != nil {
85 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {78 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
86- style := styles.statusBar // contextual ranks deliberately do not fade.79+ style := styles.StatusBar // contextual ranks deliberately do not fade.
87 if sess.selected {80 if sess.selected {
88- style = styles.accent81+ style = styles.Accent
89 }82 }
90 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)83 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
91 }84 }
92 } else {85 } else {
93 fittedMRU := fitMRU(state.mru, len(row), x, rightText)86 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
94 for i, sess := range fittedMRU {87 for i, sess := range fittedMRU {
95- style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))88+ style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU))
96 drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)89 drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)
97 }90 }
98 }91 }
99- drawRightPlainText(row, rightText, x, styles.statusBar)92+ drawRightPlainText(row, rightText, x, styles.StatusBar)
100 }93 }
101 94
102 type rankedRecent struct {95 type rankedRecent struct {
@@ -170,7 +163,7 @@ type barState struct {
170 attentionFrame int163 attentionFrame int
171 // theme is the client's terminal theme, if reported. Its zero value164 // theme is the client's terminal theme, if reported. Its zero value
172 // (Theme{}, Known: false) is a valid "no theme" default that resolves to165 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
173- // the pre-theme fallback styles (see newThemeStyles / theme.usable).166+ // the pre-theme fallback styles (see themeui.NewStyles / theme.usable).
174 theme themeui.Theme167 theme themeui.Theme
175 }168 }
176 169
@@ -434,13 +427,7 @@ func mruFutureRightReserve(rowLen int) int {
434 }427 }
435 428
436 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {429 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
437- if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {430+ return themeui.MRUFade(base, t, i, count)
438- return base
439- }
440- amount := (float64(i) / float64(count-1)) * 0.6
441- base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
442- base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount)
443- return base
444 }431 }
445 432
446 // writeBell draws the attention glyph on its visible beat, or a plain cell in433 // writeBell draws the attention glyph on its visible beat, or a plain cell in
M internal/usecase/daemon/status_test.go +42 -42
@@ -267,9 +267,9 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
267267 Known: true,
268268 TrueColor: true,
269269 }
270- styles := newThemeStyles(theme)
271- require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
272- require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
270+ styles := themeui.NewStyles(theme)
271+ require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
272+ require.False(t, styles.TabTitleActive.Equal(styles.Accent), "sanity: muted active-title style must differ from the base accent style")
273273
274274 labels := fitTabLabels(tabs, rowLen, "")
275275
@@ -286,17 +286,17 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
286286 nameEnd := x + 1 + labels[0].nameLen
287287 for i := x; i < nameEnd; i++ {
288288 require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
289- require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
289+ require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: name segment style mismatch", i)
290290 }
291291 x = nameEnd
292292 titleLen := len(labels[0].text) - labels[0].nameLen
293293 titleEnd := x + titleLen
294294 for i := x; i < titleEnd; i++ {
295295 require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
296- require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
296+ require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: title segment style mismatch", i)
297297 }
298298 x = titleEnd
299- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)
299+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 0 trailing space must keep the base style", x)
300300 x++
301301
302302 // tab 1: active, attention, no title: name, attention space+bell (both
@@ -304,16 +304,16 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
304304 nameEnd = x + 1 + labels[1].nameLen
305305 for i := x; i < nameEnd; i++ {
306306 require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
307- require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)
307+ require.True(t, row[i].Style.Equal(styles.TabNameActive), "cell %d: active name segment style mismatch", i)
308308 }
309309 require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
310310 x = nameEnd
311- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
311+ require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
312312 x++
313313 tab1Bell := x
314- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
314+ require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
315315 x++
316- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)
316+ require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 trailing space must keep the active base style", x)
317317 x++
318318
319319 // tab 2: inactive, attention, with title: name, attention space+bell
@@ -321,22 +321,22 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
321321 nameEnd = x + 1 + labels[2].nameLen
322322 for i := x; i < nameEnd; i++ {
323323 require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
324- require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)
324+ require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: tab 2 name segment style mismatch", i)
325325 }
326326 x = nameEnd
327- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
327+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
328328 x++
329329 tab2Bell := x
330- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
330+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
331331 x++
332332 titleLen = len(labels[2].text) - labels[2].nameLen
333333 titleEnd = x + titleLen
334334 for i := x; i < titleEnd; i++ {
335335 require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
336- require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)
336+ require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: tab 2 title segment style mismatch", i)
337337 }
338338 x = titleEnd
339- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)
339+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 trailing space must keep the base style", x)
340340
341341 // On a visible pulse frame, the bell glyph itself must keep the same
342342 // background as its tab's base style.
@@ -344,18 +344,18 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
344344 drawTopBarSnapshot(visibleRow, status, 1, "", styles)
345345 require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)
346346 require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
347- require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
347+ require.Equal(t, styles.Accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
348348 require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)
349349 require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
350- require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
350+ require.Equal(t, styles.StatusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
351351 })
352352
353353 t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
354- styles := resolveThemeStyles(nil)
355- require.True(t, styles.tabName.Equal(styles.statusBar))
356- require.True(t, styles.tabTitle.Equal(styles.statusBar))
357- require.True(t, styles.tabNameActive.Equal(styles.accent))
358- require.True(t, styles.tabTitleActive.Equal(styles.accent))
354+ styles := resolveStyles(nil)
355+ require.True(t, styles.TabName.Equal(styles.StatusBar))
356+ require.True(t, styles.TabTitle.Equal(styles.StatusBar))
357+ require.True(t, styles.TabNameActive.Equal(styles.Accent))
358+ require.True(t, styles.TabTitleActive.Equal(styles.Accent))
359359
360360 row := make([]renderer.Cell, rowLen)
361361 drawTopBarSnapshot(row, status, 0, "", styles)
@@ -371,15 +371,15 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
371371 require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
372372
373373 for i, c := range row[:len(tab0)] {
374- require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
374+ require.True(t, c.Style.Equal(styles.StatusBar), "cell %d should use the plain base style", i)
375375 }
376376 tab1Start := len(tab0)
377377 for i := tab1Start; i < tab1Start+len(tab1); i++ {
378- require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)
378+ require.True(t, row[i].Style.Equal(styles.Accent), "cell %d should use the plain accent style, including the blank bell cell", i)
379379 }
380380 tab2Start := tab1Start + len(tab1)
381381 for i := tab2Start; i < tab2Start+len(tab2); i++ {
382- require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)
382+ require.True(t, row[i].Style.Equal(styles.StatusBar), "cell %d should use the plain base style, including the blank bell cell", i)
383383 }
384384 })
385385 }
@@ -847,7 +847,7 @@ func TestTopBarRightAnchor(t *testing.T) {
847847 t.Run(tt.name, func(t *testing.T) {
848848 row := make([]renderer.Cell, tt.width)
849849
850- drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil))
850+ drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveStyles(nil))
851851
852852 require.Equal(t, tt.want, rowText(row))
853853 if tt.continuationCell > 0 {
@@ -1093,7 +1093,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
10931093 draw := func(state capturedRenderState) string {
10941094 t.Helper()
10951095 row := make([]renderer.Cell, 32)
1096- drawStatusBarState(row, state.bars, resolveThemeStyles(nil))
1096+ drawStatusBarState(row, state.bars, resolveStyles(nil))
10971097 return rowText(row)
10981098 }
10991099 normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}}
@@ -1139,7 +1139,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
11391139 }
11401140 row := make([]renderer.Cell, 24)
11411141
1142- drawStatusBarState(row, state, resolveThemeStyles(nil))
1142+ drawStatusBarState(row, state, resolveStyles(nil))
11431143
11441144 require.Equal(t, " cur fresh tmp  ", rowText(row))
11451145 for _, c := range row {
@@ -1153,19 +1153,19 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
11531153
11541154 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
11551155 theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1156- styles := newThemeStyles(theme)
1156+ styles := themeui.NewStyles(theme)
11571157 row := make([]renderer.Cell, 16)
11581158
11591159 drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles)
11601160
11611161 for _, idx := range []int{0, 1, 2, 3, 4} {
1162- require.True(t, row[idx].Style.Equal(styles.accent), "cell %d should use accent style", idx)
1162+ require.True(t, row[idx].Style.Equal(styles.Accent), "cell %d should use accent style", idx)
11631163 }
1164- require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)
1164+ require.NotEqual(t, styles.StatusBar.BackgroundRGB, styles.Accent.BackgroundRGB)
11651165 }
11661166
11671167 func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) {
1168- styles := resolveThemeStyles(nil)
1168+ styles := resolveStyles(nil)
11691169 row := make([]renderer.Cell, 18)
11701170 state := barState{
11711171 status: statusSnapshot{session: "cur"},
@@ -1190,8 +1190,8 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
11901190 if cell.Rune == '2' {
11911191 found = true
11921192 require.True(t, cell.Style.Bold)
1193- require.Equal(t, styles.accent.Foreground, cell.Style.Foreground)
1194- require.Equal(t, styles.accent.Background, cell.Style.Background)
1193+ require.Equal(t, styles.Accent.Foreground, cell.Style.Foreground)
1194+ require.Equal(t, styles.Accent.Background, cell.Style.Background)
11951195 break
11961196 }
11971197 }
@@ -1200,7 +1200,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
12001200
12011201 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
12021202 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1203- styles := newThemeStyles(theme)
1203+ styles := themeui.NewStyles(theme)
12041204 state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
12051205 row := make([]renderer.Cell, 16)
12061206
@@ -1209,14 +1209,14 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
12091209 firstFG := row[4].Style.ForegroundRGB.R
12101210 secondFG := row[7].Style.ForegroundRGB.R
12111211 thirdFG := row[10].Style.ForegroundRGB.R
1212- require.Equal(t, styles.statusBar.ForegroundRGB.R, firstFG)
1212+ require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG)
12131213 require.Greater(t, firstFG, secondFG)
12141214 require.Greater(t, secondFG, thirdFG)
12151215
12161216 firstBG := row[4].Style.BackgroundRGB.R
12171217 secondBG := row[7].Style.BackgroundRGB.R
12181218 thirdBG := row[10].Style.BackgroundRGB.R
1219- require.Equal(t, styles.statusBar.BackgroundRGB.R, firstBG)
1219+ require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG)
12201220 require.Greater(t, firstBG, secondBG)
12211221 require.Greater(t, secondBG, thirdBG)
12221222
@@ -1229,7 +1229,7 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
12291229 state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
12301230 row := make([]renderer.Cell, 21)
12311231
1232- drawStatusBarState(row, state, resolveThemeStyles(nil))
1232+ drawStatusBarState(row, state, resolveStyles(nil))
12331233
12341234 text := rowText(row)
12351235 require.Contains(t, text, "fresh")
@@ -1273,7 +1273,7 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
12731273 t.Run(tt.name, func(t *testing.T) {
12741274 row := make([]renderer.Cell, tt.cols)
12751275
1276- drawStatusBarState(row, state, resolveThemeStyles(nil))
1276+ drawStatusBarState(row, state, resolveStyles(nil))
12771277
12781278 text := rowText(row)
12791279 for _, want := range tt.wantShown {
@@ -1346,7 +1346,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
13461346 t.Run(tt.name, func(t *testing.T) {
13471347 row := make([]renderer.Cell, tt.width)
13481348
1349- drawStatusBarState(row, tt.state, resolveThemeStyles(nil))
1349+ drawStatusBarState(row, tt.state, resolveStyles(nil))
13501350
13511351 text := rowText(row)
13521352 if tt.want != "" {
@@ -1372,7 +1372,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
13721372 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
13731373 row := make([]renderer.Cell, 20)
13741374
1375- drawStatusBarState(row, state, resolveThemeStyles(nil))
1375+ drawStatusBarState(row, state, resolveStyles(nil))
13761376
13771377 text := rowText(row)
13781378 require.Contains(t, text, " a")
@@ -1395,7 +1395,7 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
13951395 t.Run(tt.name, func(t *testing.T) {
13961396 row := make([]renderer.Cell, tt.cols)
13971397
1398- drawStatusBarState(row, state, resolveThemeStyles(nil))
1398+ drawStatusBarState(row, state, resolveStyles(nil))
13991399
14001400 require.Equal(t, tt.want, rowText(row))
14011401 })
@@ -267,9 +267,9 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
267 Known: true,267 Known: true,
268 TrueColor: true,268 TrueColor: true,
269 }269 }
270- styles := newThemeStyles(theme)270+ styles := themeui.NewStyles(theme)
271- require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")271+ require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
272- require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")272+ require.False(t, styles.TabTitleActive.Equal(styles.Accent), "sanity: muted active-title style must differ from the base accent style")
273 273
274 labels := fitTabLabels(tabs, rowLen, "")274 labels := fitTabLabels(tabs, rowLen, "")
275 275
@@ -286,17 +286,17 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
286 nameEnd := x + 1 + labels[0].nameLen286 nameEnd := x + 1 + labels[0].nameLen
287 for i := x; i < nameEnd; i++ {287 for i := x; i < nameEnd; i++ {
288 require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)288 require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
289- require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)289+ require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: name segment style mismatch", i)
290 }290 }
291 x = nameEnd291 x = nameEnd
292 titleLen := len(labels[0].text) - labels[0].nameLen292 titleLen := len(labels[0].text) - labels[0].nameLen
293 titleEnd := x + titleLen293 titleEnd := x + titleLen
294 for i := x; i < titleEnd; i++ {294 for i := x; i < titleEnd; i++ {
295 require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)295 require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
296- require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)296+ require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: title segment style mismatch", i)
297 }297 }
298 x = titleEnd298 x = titleEnd
299- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)299+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 0 trailing space must keep the base style", x)
300 x++300 x++
301 301
302 // tab 1: active, attention, no title: name, attention space+bell (both302 // tab 1: active, attention, no title: name, attention space+bell (both
@@ -304,16 +304,16 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
304 nameEnd = x + 1 + labels[1].nameLen304 nameEnd = x + 1 + labels[1].nameLen
305 for i := x; i < nameEnd; i++ {305 for i := x; i < nameEnd; i++ {
306 require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)306 require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
307- require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)307+ require.True(t, row[i].Style.Equal(styles.TabNameActive), "cell %d: active name segment style mismatch", i)
308 }308 }
309 require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")309 require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
310 x = nameEnd310 x = nameEnd
311- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x)311+ require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
312 x++312 x++
313 tab1Bell := x313 tab1Bell := x
314- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)314+ require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
315 x++315 x++
316- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)316+ require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 trailing space must keep the active base style", x)
317 x++317 x++
318 318
319 // tab 2: inactive, attention, with title: name, attention space+bell319 // tab 2: inactive, attention, with title: name, attention space+bell
@@ -321,22 +321,22 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
321 nameEnd = x + 1 + labels[2].nameLen321 nameEnd = x + 1 + labels[2].nameLen
322 for i := x; i < nameEnd; i++ {322 for i := x; i < nameEnd; i++ {
323 require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)323 require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
324- require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)324+ require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: tab 2 name segment style mismatch", i)
325 }325 }
326 x = nameEnd326 x = nameEnd
327- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)327+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
328 x++328 x++
329 tab2Bell := x329 tab2Bell := x
330- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)330+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
331 x++331 x++
332 titleLen = len(labels[2].text) - labels[2].nameLen332 titleLen = len(labels[2].text) - labels[2].nameLen
333 titleEnd = x + titleLen333 titleEnd = x + titleLen
334 for i := x; i < titleEnd; i++ {334 for i := x; i < titleEnd; i++ {
335 require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)335 require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
336- require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)336+ require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: tab 2 title segment style mismatch", i)
337 }337 }
338 x = titleEnd338 x = titleEnd
339- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)339+ require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 trailing space must keep the base style", x)
340 340
341 // On a visible pulse frame, the bell glyph itself must keep the same341 // On a visible pulse frame, the bell glyph itself must keep the same
342 // background as its tab's base style.342 // background as its tab's base style.
@@ -344,18 +344,18 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
344 drawTopBarSnapshot(visibleRow, status, 1, "", styles)344 drawTopBarSnapshot(visibleRow, status, 1, "", styles)
345 require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)345 require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)
346 require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)346 require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
347- require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")347+ require.Equal(t, styles.Accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
348 require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)348 require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)
349 require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)349 require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
350- require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")350+ require.Equal(t, styles.StatusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
351 })351 })
352 352
353 t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {353 t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
354- styles := resolveThemeStyles(nil)354+ styles := resolveStyles(nil)
355- require.True(t, styles.tabName.Equal(styles.statusBar))355+ require.True(t, styles.TabName.Equal(styles.StatusBar))
356- require.True(t, styles.tabTitle.Equal(styles.statusBar))356+ require.True(t, styles.TabTitle.Equal(styles.StatusBar))
357- require.True(t, styles.tabNameActive.Equal(styles.accent))357+ require.True(t, styles.TabNameActive.Equal(styles.Accent))
358- require.True(t, styles.tabTitleActive.Equal(styles.accent))358+ require.True(t, styles.TabTitleActive.Equal(styles.Accent))
359 359
360 row := make([]renderer.Cell, rowLen)360 row := make([]renderer.Cell, rowLen)
361 drawTopBarSnapshot(row, status, 0, "", styles)361 drawTopBarSnapshot(row, status, 0, "", styles)
@@ -371,15 +371,15 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
371 require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))371 require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
372 372
373 for i, c := range row[:len(tab0)] {373 for i, c := range row[:len(tab0)] {
374- require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)374+ require.True(t, c.Style.Equal(styles.StatusBar), "cell %d should use the plain base style", i)
375 }375 }
376 tab1Start := len(tab0)376 tab1Start := len(tab0)
377 for i := tab1Start; i < tab1Start+len(tab1); i++ {377 for i := tab1Start; i < tab1Start+len(tab1); i++ {
378- require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)378+ require.True(t, row[i].Style.Equal(styles.Accent), "cell %d should use the plain accent style, including the blank bell cell", i)
379 }379 }
380 tab2Start := tab1Start + len(tab1)380 tab2Start := tab1Start + len(tab1)
381 for i := tab2Start; i < tab2Start+len(tab2); i++ {381 for i := tab2Start; i < tab2Start+len(tab2); i++ {
382- require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)382+ require.True(t, row[i].Style.Equal(styles.StatusBar), "cell %d should use the plain base style, including the blank bell cell", i)
383 }383 }
384 })384 })
385 }385 }
@@ -847,7 +847,7 @@ func TestTopBarRightAnchor(t *testing.T) {
847 t.Run(tt.name, func(t *testing.T) {847 t.Run(tt.name, func(t *testing.T) {
848 row := make([]renderer.Cell, tt.width)848 row := make([]renderer.Cell, tt.width)
849 849
850- drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil))850+ drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveStyles(nil))
851 851
852 require.Equal(t, tt.want, rowText(row))852 require.Equal(t, tt.want, rowText(row))
853 if tt.continuationCell > 0 {853 if tt.continuationCell > 0 {
@@ -1093,7 +1093,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
1093 draw := func(state capturedRenderState) string {1093 draw := func(state capturedRenderState) string {
1094 t.Helper()1094 t.Helper()
1095 row := make([]renderer.Cell, 32)1095 row := make([]renderer.Cell, 32)
1096- drawStatusBarState(row, state.bars, resolveThemeStyles(nil))1096+ drawStatusBarState(row, state.bars, resolveStyles(nil))
1097 return rowText(row)1097 return rowText(row)
1098 }1098 }
1099 normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}}1099 normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}}
@@ -1139,7 +1139,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
1139 }1139 }
1140 row := make([]renderer.Cell, 24)1140 row := make([]renderer.Cell, 24)
1141 1141
1142- drawStatusBarState(row, state, resolveThemeStyles(nil))1142+ drawStatusBarState(row, state, resolveStyles(nil))
1143 1143
1144 require.Equal(t, " cur fresh tmp  ", rowText(row))1144 require.Equal(t, " cur fresh tmp  ", rowText(row))
1145 for _, c := range row {1145 for _, c := range row {
@@ -1153,19 +1153,19 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
1153 1153
1154 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {1154 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
1155 theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true}1155 theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1156- styles := newThemeStyles(theme)1156+ styles := themeui.NewStyles(theme)
1157 row := make([]renderer.Cell, 16)1157 row := make([]renderer.Cell, 16)
1158 1158
1159 drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles)1159 drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles)
1160 1160
1161 for _, idx := range []int{0, 1, 2, 3, 4} {1161 for _, idx := range []int{0, 1, 2, 3, 4} {
1162- require.True(t, row[idx].Style.Equal(styles.accent), "cell %d should use accent style", idx)1162+ require.True(t, row[idx].Style.Equal(styles.Accent), "cell %d should use accent style", idx)
1163 }1163 }
1164- require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)1164+ require.NotEqual(t, styles.StatusBar.BackgroundRGB, styles.Accent.BackgroundRGB)
1165 }1165 }
1166 1166
1167 func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) {1167 func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) {
1168- styles := resolveThemeStyles(nil)1168+ styles := resolveStyles(nil)
1169 row := make([]renderer.Cell, 18)1169 row := make([]renderer.Cell, 18)
1170 state := barState{1170 state := barState{
1171 status: statusSnapshot{session: "cur"},1171 status: statusSnapshot{session: "cur"},
@@ -1190,8 +1190,8 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
1190 if cell.Rune == '2' {1190 if cell.Rune == '2' {
1191 found = true1191 found = true
1192 require.True(t, cell.Style.Bold)1192 require.True(t, cell.Style.Bold)
1193- require.Equal(t, styles.accent.Foreground, cell.Style.Foreground)1193+ require.Equal(t, styles.Accent.Foreground, cell.Style.Foreground)
1194- require.Equal(t, styles.accent.Background, cell.Style.Background)1194+ require.Equal(t, styles.Accent.Background, cell.Style.Background)
1195 break1195 break
1196 }1196 }
1197 }1197 }
@@ -1200,7 +1200,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
1200 1200
1201 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {1201 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1202 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}1202 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1203- styles := newThemeStyles(theme)1203+ styles := themeui.NewStyles(theme)
1204 state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}1204 state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1205 row := make([]renderer.Cell, 16)1205 row := make([]renderer.Cell, 16)
1206 1206
@@ -1209,14 +1209,14 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1209 firstFG := row[4].Style.ForegroundRGB.R1209 firstFG := row[4].Style.ForegroundRGB.R
1210 secondFG := row[7].Style.ForegroundRGB.R1210 secondFG := row[7].Style.ForegroundRGB.R
1211 thirdFG := row[10].Style.ForegroundRGB.R1211 thirdFG := row[10].Style.ForegroundRGB.R
1212- require.Equal(t, styles.statusBar.ForegroundRGB.R, firstFG)1212+ require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG)
1213 require.Greater(t, firstFG, secondFG)1213 require.Greater(t, firstFG, secondFG)
1214 require.Greater(t, secondFG, thirdFG)1214 require.Greater(t, secondFG, thirdFG)
1215 1215
1216 firstBG := row[4].Style.BackgroundRGB.R1216 firstBG := row[4].Style.BackgroundRGB.R
1217 secondBG := row[7].Style.BackgroundRGB.R1217 secondBG := row[7].Style.BackgroundRGB.R
1218 thirdBG := row[10].Style.BackgroundRGB.R1218 thirdBG := row[10].Style.BackgroundRGB.R
1219- require.Equal(t, styles.statusBar.BackgroundRGB.R, firstBG)1219+ require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG)
1220 require.Greater(t, firstBG, secondBG)1220 require.Greater(t, firstBG, secondBG)
1221 require.Greater(t, secondBG, thirdBG)1221 require.Greater(t, secondBG, thirdBG)
1222 1222
@@ -1229,7 +1229,7 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1229 state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}1229 state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
1230 row := make([]renderer.Cell, 21)1230 row := make([]renderer.Cell, 21)
1231 1231
1232- drawStatusBarState(row, state, resolveThemeStyles(nil))1232+ drawStatusBarState(row, state, resolveStyles(nil))
1233 1233
1234 text := rowText(row)1234 text := rowText(row)
1235 require.Contains(t, text, "fresh")1235 require.Contains(t, text, "fresh")
@@ -1273,7 +1273,7 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
1273 t.Run(tt.name, func(t *testing.T) {1273 t.Run(tt.name, func(t *testing.T) {
1274 row := make([]renderer.Cell, tt.cols)1274 row := make([]renderer.Cell, tt.cols)
1275 1275
1276- drawStatusBarState(row, state, resolveThemeStyles(nil))1276+ drawStatusBarState(row, state, resolveStyles(nil))
1277 1277
1278 text := rowText(row)1278 text := rowText(row)
1279 for _, want := range tt.wantShown {1279 for _, want := range tt.wantShown {
@@ -1346,7 +1346,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1346 t.Run(tt.name, func(t *testing.T) {1346 t.Run(tt.name, func(t *testing.T) {
1347 row := make([]renderer.Cell, tt.width)1347 row := make([]renderer.Cell, tt.width)
1348 1348
1349- drawStatusBarState(row, tt.state, resolveThemeStyles(nil))1349+ drawStatusBarState(row, tt.state, resolveStyles(nil))
1350 1350
1351 text := rowText(row)1351 text := rowText(row)
1352 if tt.want != "" {1352 if tt.want != "" {
@@ -1372,7 +1372,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1372 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}1372 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1373 row := make([]renderer.Cell, 20)1373 row := make([]renderer.Cell, 20)
1374 1374
1375- drawStatusBarState(row, state, resolveThemeStyles(nil))1375+ drawStatusBarState(row, state, resolveStyles(nil))
1376 1376
1377 text := rowText(row)1377 text := rowText(row)
1378 require.Contains(t, text, " a")1378 require.Contains(t, text, " a")
@@ -1395,7 +1395,7 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1395 t.Run(tt.name, func(t *testing.T) {1395 t.Run(tt.name, func(t *testing.T) {
1396 row := make([]renderer.Cell, tt.cols)1396 row := make([]renderer.Cell, tt.cols)
1397 1397
1398- drawStatusBarState(row, state, resolveThemeStyles(nil))1398+ drawStatusBarState(row, state, resolveStyles(nil))
1399 1399
1400 require.Equal(t, tt.want, rowText(row))1400 require.Equal(t, tt.want, rowText(row))
1401 })1401 })
A internal/usecase/theme/palette_scan_test.go +141 -0
@@ -0,0 +1,141 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "reflect"
6+ "strconv"
7+ "testing"
8+
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+type paletteEvent struct {
13+ slot int
14+ rgb renderer.RGB
15+}
16+
17+func TestScannerExtractsPaletteResponses(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ chunks []string
21+ wantPalette []paletteEvent
22+ wantColors []int
23+ wantOut string
24+ }{
25+ {
26+ name: "BEL hash and ST rgb",
27+ chunks: []string{"a\x1b]4;0;#112233\x07b\x1b]4;15;rgb:4455/6677/8899\x1b\\c"},
28+ wantPalette: []paletteEvent{{slot: 0, rgb: renderer.RGB{R: 0x11, G: 0x22, B: 0x33}}, {slot: 15, rgb: renderer.RGB{R: 0x44, G: 0x66, B: 0x88}}},
29+ wantOut: "abc",
30+ },
31+ {
32+ name: "split prefix body and terminator",
33+ chunks: []string{"a\x1b]4;", "1;rgb:12", "34/abcd/ffff\x1b", "\\b"},
34+ wantPalette: []paletteEvent{{slot: 1, rgb: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}}},
35+ wantOut: "ab",
36+ },
37+ {
38+ name: "interleaved defaults and multiple replies",
39+ chunks: []string{"a\x1b]10;#010203\x07\x1b]4;2;#102030\x07\x1b]11;#040506\x07\x1b]4;2;rgb:aa/bb/cc\x07z"},
40+ wantPalette: []paletteEvent{{slot: 2, rgb: renderer.RGB{R: 0x10, G: 0x20, B: 0x30}}, {slot: 2, rgb: renderer.RGB{R: 0xaa, G: 0xbb, B: 0xcc}}},
41+ wantColors: []int{10, 11},
42+ wantOut: "az",
43+ },
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ var s Scanner
49+ var palette []paletteEvent
50+ var colors []int
51+ var out bytes.Buffer
52+ for _, chunk := range tt.chunks {
53+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {
54+ colors = append(colors, kind)
55+ }, func(slot int, rgb renderer.RGB) {
56+ palette = append(palette, paletteEvent{slot: slot, rgb: rgb})
57+ }, func(bool) {}, func(b []byte) {
58+ out.Write(b)
59+ })
60+ }
61+ if !reflect.DeepEqual(palette, tt.wantPalette) {
62+ t.Fatalf("palette=%+v want %+v", palette, tt.wantPalette)
63+ }
64+ if !reflect.DeepEqual(colors, tt.wantColors) {
65+ t.Fatalf("colors=%v want %v", colors, tt.wantColors)
66+ }
67+ if out.String() != tt.wantOut {
68+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
69+ }
70+ })
71+ }
72+}
73+
74+func TestScannerReportsEveryValidPaletteSlot(t *testing.T) {
75+ var data bytes.Buffer
76+ for slot := 0; slot < 16; slot++ {
77+ data.WriteString("\x1b]4;")
78+ data.WriteString(strconv.Itoa(slot))
79+ data.WriteString(";#010203\x07")
80+ }
81+
82+ var s Scanner
83+ var slots []int
84+ var out bytes.Buffer
85+ s.Scan(data.Bytes(), func(int, renderer.RGB) {}, func(slot int, rgb renderer.RGB) {
86+ if rgb != (renderer.RGB{R: 1, G: 2, B: 3}) {
87+ t.Fatalf("slot %d rgb=%+v", slot, rgb)
88+ }
89+ slots = append(slots, slot)
90+ }, func(bool) {}, func(b []byte) {
91+ out.Write(b)
92+ })
93+
94+ if want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; !reflect.DeepEqual(slots, want) {
95+ t.Fatalf("slots=%v want %v", slots, want)
96+ }
97+ if out.Len() != 0 {
98+ t.Fatalf("passthrough=%q want empty", out.String())
99+ }
100+}
101+
102+func TestScannerForwardsMalformedPaletteResponses(t *testing.T) {
103+ const malformed = "\x1b]4;;#010203\x07" +
104+ "\x1b]4;-1;#010203\x07" +
105+ "\x1b]4;cat;#010203\x1b\\" +
106+ "\x1b]4;16;#010203\x07" +
107+ "\x1b]4;999999999999999999;#010203\x07" +
108+ "\x1b]4;3;not-a-color\x07"
109+
110+ var s Scanner
111+ var out bytes.Buffer
112+ paletteCalls := 0
113+ s.Scan([]byte("before"+malformed+"after"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {
114+ paletteCalls++
115+ }, func(bool) {}, func(b []byte) {
116+ out.Write(b)
117+ })
118+
119+ if paletteCalls != 0 {
120+ t.Fatalf("palette callbacks=%d want 0", paletteCalls)
121+ }
122+ if got, want := out.String(), "before"+malformed+"after"; got != want {
123+ t.Fatalf("passthrough=%q want %q", got, want)
124+ }
125+}
126+
127+func TestScannerFlushesTruncatedPaletteResponseAtPendingLimit(t *testing.T) {
128+ var s Scanner
129+ var out bytes.Buffer
130+ partial := append([]byte("\x1b]4;15;rgb:"), bytes.Repeat([]byte("a"), maxPending)...)
131+ s.Scan(partial, func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
132+ out.Write(b)
133+ })
134+ s.Scan([]byte("z"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
135+ out.Write(b)
136+ })
137+
138+ if got, want := out.String(), string(partial)+"z"; got != want {
139+ t.Fatalf("passthrough=%q want %q", got, want)
140+ }
141+}
@@ -0,0 +1,141 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "reflect"
6+ "strconv"
7+ "testing"
8+
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+type paletteEvent struct {
13+ slot int
14+ rgb renderer.RGB
15+}
16+
17+func TestScannerExtractsPaletteResponses(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ chunks []string
21+ wantPalette []paletteEvent
22+ wantColors []int
23+ wantOut string
24+ }{
25+ {
26+ name: "BEL hash and ST rgb",
27+ chunks: []string{"a\x1b]4;0;#112233\x07b\x1b]4;15;rgb:4455/6677/8899\x1b\\c"},
28+ wantPalette: []paletteEvent{{slot: 0, rgb: renderer.RGB{R: 0x11, G: 0x22, B: 0x33}}, {slot: 15, rgb: renderer.RGB{R: 0x44, G: 0x66, B: 0x88}}},
29+ wantOut: "abc",
30+ },
31+ {
32+ name: "split prefix body and terminator",
33+ chunks: []string{"a\x1b]4;", "1;rgb:12", "34/abcd/ffff\x1b", "\\b"},
34+ wantPalette: []paletteEvent{{slot: 1, rgb: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}}},
35+ wantOut: "ab",
36+ },
37+ {
38+ name: "interleaved defaults and multiple replies",
39+ chunks: []string{"a\x1b]10;#010203\x07\x1b]4;2;#102030\x07\x1b]11;#040506\x07\x1b]4;2;rgb:aa/bb/cc\x07z"},
40+ wantPalette: []paletteEvent{{slot: 2, rgb: renderer.RGB{R: 0x10, G: 0x20, B: 0x30}}, {slot: 2, rgb: renderer.RGB{R: 0xaa, G: 0xbb, B: 0xcc}}},
41+ wantColors: []int{10, 11},
42+ wantOut: "az",
43+ },
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ var s Scanner
49+ var palette []paletteEvent
50+ var colors []int
51+ var out bytes.Buffer
52+ for _, chunk := range tt.chunks {
53+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {
54+ colors = append(colors, kind)
55+ }, func(slot int, rgb renderer.RGB) {
56+ palette = append(palette, paletteEvent{slot: slot, rgb: rgb})
57+ }, func(bool) {}, func(b []byte) {
58+ out.Write(b)
59+ })
60+ }
61+ if !reflect.DeepEqual(palette, tt.wantPalette) {
62+ t.Fatalf("palette=%+v want %+v", palette, tt.wantPalette)
63+ }
64+ if !reflect.DeepEqual(colors, tt.wantColors) {
65+ t.Fatalf("colors=%v want %v", colors, tt.wantColors)
66+ }
67+ if out.String() != tt.wantOut {
68+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
69+ }
70+ })
71+ }
72+}
73+
74+func TestScannerReportsEveryValidPaletteSlot(t *testing.T) {
75+ var data bytes.Buffer
76+ for slot := 0; slot < 16; slot++ {
77+ data.WriteString("\x1b]4;")
78+ data.WriteString(strconv.Itoa(slot))
79+ data.WriteString(";#010203\x07")
80+ }
81+
82+ var s Scanner
83+ var slots []int
84+ var out bytes.Buffer
85+ s.Scan(data.Bytes(), func(int, renderer.RGB) {}, func(slot int, rgb renderer.RGB) {
86+ if rgb != (renderer.RGB{R: 1, G: 2, B: 3}) {
87+ t.Fatalf("slot %d rgb=%+v", slot, rgb)
88+ }
89+ slots = append(slots, slot)
90+ }, func(bool) {}, func(b []byte) {
91+ out.Write(b)
92+ })
93+
94+ if want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; !reflect.DeepEqual(slots, want) {
95+ t.Fatalf("slots=%v want %v", slots, want)
96+ }
97+ if out.Len() != 0 {
98+ t.Fatalf("passthrough=%q want empty", out.String())
99+ }
100+}
101+
102+func TestScannerForwardsMalformedPaletteResponses(t *testing.T) {
103+ const malformed = "\x1b]4;;#010203\x07" +
104+ "\x1b]4;-1;#010203\x07" +
105+ "\x1b]4;cat;#010203\x1b\\" +
106+ "\x1b]4;16;#010203\x07" +
107+ "\x1b]4;999999999999999999;#010203\x07" +
108+ "\x1b]4;3;not-a-color\x07"
109+
110+ var s Scanner
111+ var out bytes.Buffer
112+ paletteCalls := 0
113+ s.Scan([]byte("before"+malformed+"after"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {
114+ paletteCalls++
115+ }, func(bool) {}, func(b []byte) {
116+ out.Write(b)
117+ })
118+
119+ if paletteCalls != 0 {
120+ t.Fatalf("palette callbacks=%d want 0", paletteCalls)
121+ }
122+ if got, want := out.String(), "before"+malformed+"after"; got != want {
123+ t.Fatalf("passthrough=%q want %q", got, want)
124+ }
125+}
126+
127+func TestScannerFlushesTruncatedPaletteResponseAtPendingLimit(t *testing.T) {
128+ var s Scanner
129+ var out bytes.Buffer
130+ partial := append([]byte("\x1b]4;15;rgb:"), bytes.Repeat([]byte("a"), maxPending)...)
131+ s.Scan(partial, func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
132+ out.Write(b)
133+ })
134+ s.Scan([]byte("z"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
135+ out.Write(b)
136+ })
137+
138+ if got, want := out.String(), string(partial)+"z"; got != want {
139+ t.Fatalf("passthrough=%q want %q", got, want)
140+ }
141+}
A internal/usecase/theme/styles.go +104 -0
@@ -0,0 +1,104 @@
1+package theme
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+const accentContrastMin = 3.0
10+
11+// RelativeLuminance returns a color's WCAG 2.x relative luminance.
12+func RelativeLuminance(color renderer.RGB) float64 {
13+ linearize := func(channel uint8) float64 {
14+ srgb := float64(channel) / 255
15+ if srgb <= 0.04045 {
16+ return srgb / 12.92
17+ }
18+ return math.Pow((srgb+0.055)/1.055, 2.4)
19+ }
20+
21+ return 0.2126*linearize(color.R) +
22+ 0.7152*linearize(color.G) +
23+ 0.0722*linearize(color.B)
24+}
25+
26+// ContrastRatio returns the WCAG 2.x contrast ratio between two colors.
27+func ContrastRatio(a, b renderer.RGB) float64 {
28+ aLuminance := RelativeLuminance(a)
29+ bLuminance := RelativeLuminance(b)
30+ lighter := math.Max(aLuminance, bLuminance)
31+ darker := math.Min(aLuminance, bLuminance)
32+ return (lighter + 0.05) / (darker + 0.05)
33+}
34+
35+// PulseColor derives the attention pulse color at intensity while preserving
36+// the base style's remaining attributes.
37+func PulseColor(base renderer.Style, intensity float64) renderer.Style {
38+ style := base
39+ style.Bold = true
40+ if base.HasForegroundRGB && base.HasBackgroundRGB {
41+ style.ForegroundRGB = Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
42+ } else {
43+ style.HasForegroundRGB = false
44+ style.Foreground = 244 + int(intensity*11)
45+ }
46+ return style
47+}
48+
49+// MRUFade fades an MRU style by position, reaching 60 percent of the terminal
50+// background color at the oldest entry.
51+func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
52+ if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
53+ return base
54+ }
55+ amount := (float64(i) / float64(count-1)) * 0.6
56+ base.ForegroundRGB = Blend(base.ForegroundRGB, t.Background, amount)
57+ base.BackgroundRGB = Blend(base.BackgroundRGB, t.Background, amount)
58+ return base
59+}
60+
61+// Styles is the resolved set of terminal chrome styles for a client theme.
62+type Styles struct {
63+ StatusBar renderer.Style
64+ Accent renderer.Style
65+ Border renderer.Style
66+ Selection renderer.Style
67+ CopyStatus renderer.Style
68+ PaletteDesc renderer.Style
69+
70+ TabName renderer.Style
71+ TabNameActive renderer.Style
72+ TabTitle renderer.Style
73+ TabTitleActive renderer.Style
74+
75+ PickerName renderer.Style
76+ PickerSelectionName renderer.Style
77+ PickerSelectionMuted renderer.Style
78+ PickerSeparator renderer.Style
79+}
80+
81+// NewStyles composes the terminal chrome styles from the semantic theme styles.
82+func NewStyles(t Theme) Styles {
83+ statusBar := StatusBarStyle(t)
84+ accent := AccentStyle(t)
85+ selection := SelectionStyle(t)
86+ return Styles{
87+ StatusBar: statusBar,
88+ Accent: accent,
89+ Border: BorderStyle(t),
90+ Selection: selection,
91+ CopyStatus: selection,
92+ PaletteDesc: MutedTextStyle(t),
93+
94+ TabName: EmphasisStyle(statusBar, t),
95+ TabNameActive: EmphasisStyle(accent, t),
96+ TabTitle: PaletteMutedVariantStyle(statusBar, t),
97+ TabTitleActive: PaletteMutedVariantStyle(accent, t),
98+
99+ PickerName: EmphasisStyle(renderer.DefaultStyle(), t),
100+ PickerSelectionName: EmphasisStyle(selection, t),
101+ PickerSelectionMuted: PaletteMutedVariantStyle(selection, t),
102+ PickerSeparator: MutedTextStyle(t),
103+ }
104+}
@@ -0,0 +1,104 @@
1+package theme
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+const accentContrastMin = 3.0
10+
11+// RelativeLuminance returns a color's WCAG 2.x relative luminance.
12+func RelativeLuminance(color renderer.RGB) float64 {
13+ linearize := func(channel uint8) float64 {
14+ srgb := float64(channel) / 255
15+ if srgb <= 0.04045 {
16+ return srgb / 12.92
17+ }
18+ return math.Pow((srgb+0.055)/1.055, 2.4)
19+ }
20+
21+ return 0.2126*linearize(color.R) +
22+ 0.7152*linearize(color.G) +
23+ 0.0722*linearize(color.B)
24+}
25+
26+// ContrastRatio returns the WCAG 2.x contrast ratio between two colors.
27+func ContrastRatio(a, b renderer.RGB) float64 {
28+ aLuminance := RelativeLuminance(a)
29+ bLuminance := RelativeLuminance(b)
30+ lighter := math.Max(aLuminance, bLuminance)
31+ darker := math.Min(aLuminance, bLuminance)
32+ return (lighter + 0.05) / (darker + 0.05)
33+}
34+
35+// PulseColor derives the attention pulse color at intensity while preserving
36+// the base style's remaining attributes.
37+func PulseColor(base renderer.Style, intensity float64) renderer.Style {
38+ style := base
39+ style.Bold = true
40+ if base.HasForegroundRGB && base.HasBackgroundRGB {
41+ style.ForegroundRGB = Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
42+ } else {
43+ style.HasForegroundRGB = false
44+ style.Foreground = 244 + int(intensity*11)
45+ }
46+ return style
47+}
48+
49+// MRUFade fades an MRU style by position, reaching 60 percent of the terminal
50+// background color at the oldest entry.
51+func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
52+ if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
53+ return base
54+ }
55+ amount := (float64(i) / float64(count-1)) * 0.6
56+ base.ForegroundRGB = Blend(base.ForegroundRGB, t.Background, amount)
57+ base.BackgroundRGB = Blend(base.BackgroundRGB, t.Background, amount)
58+ return base
59+}
60+
61+// Styles is the resolved set of terminal chrome styles for a client theme.
62+type Styles struct {
63+ StatusBar renderer.Style
64+ Accent renderer.Style
65+ Border renderer.Style
66+ Selection renderer.Style
67+ CopyStatus renderer.Style
68+ PaletteDesc renderer.Style
69+
70+ TabName renderer.Style
71+ TabNameActive renderer.Style
72+ TabTitle renderer.Style
73+ TabTitleActive renderer.Style
74+
75+ PickerName renderer.Style
76+ PickerSelectionName renderer.Style
77+ PickerSelectionMuted renderer.Style
78+ PickerSeparator renderer.Style
79+}
80+
81+// NewStyles composes the terminal chrome styles from the semantic theme styles.
82+func NewStyles(t Theme) Styles {
83+ statusBar := StatusBarStyle(t)
84+ accent := AccentStyle(t)
85+ selection := SelectionStyle(t)
86+ return Styles{
87+ StatusBar: statusBar,
88+ Accent: accent,
89+ Border: BorderStyle(t),
90+ Selection: selection,
91+ CopyStatus: selection,
92+ PaletteDesc: MutedTextStyle(t),
93+
94+ TabName: EmphasisStyle(statusBar, t),
95+ TabNameActive: EmphasisStyle(accent, t),
96+ TabTitle: PaletteMutedVariantStyle(statusBar, t),
97+ TabTitleActive: PaletteMutedVariantStyle(accent, t),
98+
99+ PickerName: EmphasisStyle(renderer.DefaultStyle(), t),
100+ PickerSelectionName: EmphasisStyle(selection, t),
101+ PickerSelectionMuted: PaletteMutedVariantStyle(selection, t),
102+ PickerSeparator: MutedTextStyle(t),
103+ }
104+}
A internal/usecase/theme/styles_test.go +579 -0
@@ -0,0 +1,579 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRelativeLuminance(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ color renderer.RGB
14+ want float64
15+ }{
16+ {name: "black", color: renderer.RGB{}, want: 0},
17+ {name: "white", color: renderer.RGB{R: 255, G: 255, B: 255}, want: 1},
18+ {name: "sRGB linearization breakpoint", color: renderer.RGB{R: 10}, want: 0.2126 * (10.0 / 255.0 / 12.92)},
19+ {name: "nonlinear sRGB", color: renderer.RGB{R: 128, G: 128, B: 128}, want: 0.21586050011389923},
20+ {name: "red", color: renderer.RGB{R: 255}, want: 0.2126},
21+ {name: "green", color: renderer.RGB{G: 255}, want: 0.7152},
22+ {name: "blue", color: renderer.RGB{B: 255}, want: 0.0722},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got := RelativeLuminance(tt.color)
28+ require.InDelta(t, tt.want, got, 1e-12)
29+ require.Equal(t, got, RelativeLuminance(tt.color))
30+ })
31+ }
32+}
33+
34+func TestContrastRatio(t *testing.T) {
35+ black := renderer.RGB{}
36+ white := renderer.RGB{R: 255, G: 255, B: 255}
37+ middle := renderer.RGB{R: 123, G: 45, B: 67}
38+
39+ require.InDelta(t, 21, ContrastRatio(black, white), 1e-12)
40+ require.InDelta(t, 1, ContrastRatio(middle, middle), 1e-12)
41+ require.Equal(t, ContrastRatio(black, middle), ContrastRatio(middle, black))
42+}
43+
44+func TestAccentContrastMinimum(t *testing.T) {
45+ require.Equal(t, 3.0, accentContrastMin)
46+}
47+
48+func TestPulseColor(t *testing.T) {
49+ rgbBase := renderer.Style{
50+ Italic: true,
51+ Foreground: 7,
52+ Background: 8,
53+ HasForegroundRGB: true,
54+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
55+ HasBackgroundRGB: true,
56+ BackgroundRGB: renderer.RGB{R: 20, G: 40, B: 60},
57+ HasUnderlineColor: true,
58+ UnderlineColor: 4,
59+ HasUnderlineColorRGB: false,
60+ }
61+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
62+ mixedBase := rgbBase
63+ mixedBase.HasBackgroundRGB = false
64+
65+ tests := []struct {
66+ name string
67+ base renderer.Style
68+ intensity float64
69+ want renderer.Style
70+ }{
71+ {
72+ name: "RGB starts at the background",
73+ base: rgbBase,
74+ intensity: 0,
75+ want: func() renderer.Style {
76+ style := rgbBase
77+ style.Bold = true
78+ style.ForegroundRGB = renderer.RGB{R: 20, G: 40, B: 60}
79+ return style
80+ }(),
81+ },
82+ {
83+ name: "RGB blends halfway with rounding",
84+ base: rgbBase,
85+ intensity: 0.5,
86+ want: func() renderer.Style {
87+ style := rgbBase
88+ style.Bold = true
89+ style.ForegroundRGB = renderer.RGB{R: 110, G: 70, B: 55}
90+ return style
91+ }(),
92+ },
93+ {
94+ name: "RGB reaches the foreground",
95+ base: rgbBase,
96+ intensity: 1,
97+ want: func() renderer.Style {
98+ style := rgbBase
99+ style.Bold = true
100+ return style
101+ }(),
102+ },
103+ {
104+ name: "RGB foreground without RGB background uses indexed grayscale",
105+ base: mixedBase,
106+ intensity: 0.5,
107+ want: func() renderer.Style {
108+ style := mixedBase
109+ style.Bold = true
110+ style.HasForegroundRGB = false
111+ style.Foreground = 249
112+ return style
113+ }(),
114+ },
115+ {
116+ name: "indexed color uses xterm grayscale start",
117+ base: indexedBase,
118+ intensity: 0,
119+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 244, Background: -1},
120+ },
121+ {
122+ name: "indexed color truncates the grayscale ramp",
123+ base: indexedBase,
124+ intensity: 0.5,
125+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 249, Background: -1},
126+ },
127+ {
128+ name: "indexed color reaches xterm grayscale end",
129+ base: indexedBase,
130+ intensity: 1,
131+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 255, Background: -1},
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ require.Equal(t, tt.want, PulseColor(tt.base, tt.intensity))
138+ })
139+ }
140+}
141+
142+func TestMRUFade(t *testing.T) {
143+ base := renderer.Style{
144+ Bold: true,
145+ Foreground: 7,
146+ Background: 8,
147+ HasForegroundRGB: true,
148+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
149+ HasBackgroundRGB: true,
150+ BackgroundRGB: renderer.RGB{R: 40, G: 60, B: 80},
151+ HasUnderlineColorRGB: true,
152+ UnderlineColorRGB: renderer.RGB{R: 1, G: 2, B: 3},
153+ }
154+ terminalTheme := Theme{HasBG: true, Background: renderer.RGB{R: 10, G: 20, B: 30}}
155+
156+ tests := []struct {
157+ name string
158+ base renderer.Style
159+ theme Theme
160+ i int
161+ count int
162+ want renderer.Style
163+ }{
164+ {name: "single entry is unchanged", base: base, theme: terminalTheme, count: 1, want: base},
165+ {name: "missing theme background is unchanged", base: base, theme: Theme{}, i: 1, count: 3, want: base},
166+ {name: "indexed base is unchanged", base: renderer.DefaultStyle(), theme: terminalTheme, i: 1, count: 3, want: renderer.DefaultStyle()},
167+ {name: "newest entry starts unfaded", base: base, theme: terminalTheme, count: 3, want: base},
168+ {
169+ name: "middle entry fades thirty percent toward terminal background",
170+ base: base,
171+ theme: terminalTheme,
172+ i: 1,
173+ count: 3,
174+ want: func() renderer.Style {
175+ style := base
176+ style.ForegroundRGB = renderer.RGB{R: 143, G: 76, B: 44}
177+ style.BackgroundRGB = renderer.RGB{R: 31, G: 48, B: 65}
178+ return style
179+ }(),
180+ },
181+ {
182+ name: "oldest entry fades sixty percent toward terminal background",
183+ base: base,
184+ theme: terminalTheme,
185+ i: 2,
186+ count: 3,
187+ want: func() renderer.Style {
188+ style := base
189+ style.ForegroundRGB = renderer.RGB{R: 86, G: 52, B: 38}
190+ style.BackgroundRGB = renderer.RGB{R: 22, G: 36, B: 50}
191+ return style
192+ }(),
193+ },
194+ }
195+
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ require.Equal(t, tt.want, MRUFade(tt.base, tt.theme, tt.i, tt.count))
199+ })
200+ }
201+}
202+
203+func TestThemePaletteColor(t *testing.T) {
204+ palette := [16]renderer.RGB{
205+ 3: {R: 0x11, G: 0x22, B: 0x33},
206+ 7: {R: 0xaa, G: 0xbb, B: 0xcc},
207+ }
208+ theme := Theme{
209+ UsePalette: true,
210+ Palette: palette,
211+ PaletteKnown: 1<<3 | 1<<7,
212+ }
213+
214+ tests := []struct {
215+ name string
216+ slot int
217+ want renderer.RGB
218+ ok bool
219+ }{
220+ {name: "known slot", slot: 3, want: palette[3], ok: true},
221+ {name: "unknown slot", slot: 4},
222+ {name: "negative slot", slot: -1},
223+ {name: "slot above palette", slot: 16},
224+ }
225+
226+ for _, tt := range tests {
227+ t.Run(tt.name, func(t *testing.T) {
228+ got, ok := theme.PaletteColor(tt.slot)
229+ require.Equal(t, tt.ok, ok)
230+ require.Equal(t, tt.want, got)
231+ })
232+ }
233+
234+ theme.UsePalette = false
235+ got, ok := theme.PaletteColor(3)
236+ require.False(t, ok)
237+ require.Equal(t, renderer.RGB{}, got)
238+}
239+
240+func TestBuiltinThemesDoNotUsePalette(t *testing.T) {
241+ for name, theme := range map[string]Theme{
242+ "dark": BuiltinDark,
243+ "light": BuiltinLight,
244+ } {
245+ t.Run(name, func(t *testing.T) {
246+ require.False(t, theme.UsePalette)
247+ require.Zero(t, theme.PaletteKnown)
248+ require.Equal(t, [16]renderer.RGB{}, theme.Palette)
249+ })
250+ }
251+}
252+
253+func TestThemeIsComparable(t *testing.T) {
254+ themes := map[Theme]bool{BuiltinDark: true}
255+ require.True(t, themes[BuiltinDark])
256+}
257+
258+func TestPaletteAccentBackground(t *testing.T) {
259+ base := Theme{
260+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
261+ Background: renderer.RGB{R: 100, G: 100, B: 100},
262+ HasFG: true,
263+ HasBG: true,
264+ Known: true,
265+ TrueColor: true,
266+ UsePalette: true,
267+ }
268+ neutral := Blend(base.Background, base.Foreground, accentBlend)
269+
270+ tests := []struct {
271+ name string
272+ palette [16]renderer.RGB
273+ known uint16
274+ usePalette bool
275+ want renderer.RGB
276+ wantContrastMin bool
277+ }{
278+ {
279+ name: "slot 4 supplies a contrasting accent background",
280+ palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}},
281+ known: 1<<4 | 1<<12,
282+ usePalette: true,
283+ want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend),
284+ wantContrastMin: true,
285+ },
286+ {
287+ name: "slot 4 failure escalates to slot 12",
288+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}},
289+ known: 1<<4 | 1<<12,
290+ usePalette: true,
291+ want: Blend(base.Background, renderer.RGB{}, accentBlend),
292+ wantContrastMin: true,
293+ },
294+ {
295+ name: "both palette blues failing uses exact neutral blend",
296+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
297+ known: 1<<4 | 1<<12,
298+ usePalette: true,
299+ want: neutral,
300+ },
301+ {
302+ name: "missing palette blues uses exact neutral blend",
303+ usePalette: true,
304+ want: neutral,
305+ },
306+ {
307+ name: "palette disabled preserves neutral accent",
308+ palette: [16]renderer.RGB{4: {B: 255}},
309+ known: 1 << 4,
310+ usePalette: false,
311+ want: neutral,
312+ },
313+ }
314+
315+ for _, tt := range tests {
316+ t.Run(tt.name, func(t *testing.T) {
317+ theme := base
318+ theme.Palette = tt.palette
319+ theme.PaletteKnown = tt.known
320+ theme.UsePalette = tt.usePalette
321+
322+ got := AccentStyle(theme)
323+ require.True(t, got.HasBackgroundRGB)
324+ require.Equal(t, tt.want, got.BackgroundRGB)
325+ if tt.wantContrastMin {
326+ require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin)
327+ }
328+ })
329+ }
330+}
331+
332+func TestPaletteTitleHueDerivation(t *testing.T) {
333+ base := Theme{
334+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
335+ Background: renderer.RGB{R: 100, G: 100, B: 100},
336+ HasFG: true,
337+ HasBG: true,
338+ Known: true,
339+ TrueColor: true,
340+ UsePalette: true,
341+ }
342+
343+ tests := []struct {
344+ name string
345+ theme Theme
346+ palette [16]renderer.RGB
347+ known uint16
348+ }{
349+ {
350+ name: "slot 4 is used when it contrasts for each title",
351+ theme: Theme{
352+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
353+ Background: renderer.RGB{R: 50, G: 50, B: 50},
354+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
355+ },
356+ palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}},
357+
358+ known: 1<<4 | 1<<12,
359+ },
360+ {
361+ name: "each title escalates independently from slot 4 to slot 12",
362+ palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}},
363+ known: 1<<4 | 1<<12,
364+ },
365+ {
366+ name: "both title hues failing retains neutral muted styles",
367+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
368+ known: 1<<4 | 1<<12,
369+ },
370+ }
371+
372+ for _, tt := range tests {
373+ t.Run(tt.name, func(t *testing.T) {
374+ theme := base
375+ if tt.theme.Known {
376+ theme = tt.theme
377+ }
378+ theme.Palette = tt.palette
379+ theme.PaletteKnown = tt.known
380+ styles := NewStyles(theme)
381+ status := StatusBarStyle(theme)
382+ accent := AccentStyle(theme)
383+
384+ if tt.name == "both title hues failing retains neutral muted styles" {
385+ require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme)))
386+ require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
387+ return
388+ }
389+
390+ require.True(t, styles.TabTitle.HasForegroundRGB)
391+ require.True(t, styles.TabTitleActive.HasForegroundRGB)
392+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin)
393+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin)
394+
395+ if tt.name == "slot 4 is used when it contrasts for each title" {
396+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB)
397+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
398+ }
399+ if tt.name == "each title escalates independently from slot 4 to slot 12" {
400+ require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB)
401+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
402+ }
403+ })
404+ }
405+}
406+
407+func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) {
408+ base := Theme{
409+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
410+ Background: renderer.RGB{R: 50, G: 50, B: 50},
411+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
412+ }
413+
414+ tests := []struct {
415+ name string
416+ theme Theme
417+ wantForeground renderer.RGB
418+ wantIndexed int
419+ }{
420+ {
421+ name: "RGB palette foreground",
422+ theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(),
423+ wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend),
424+ wantIndexed: -1,
425+ },
426+ {
427+ name: "indexed foreground fallback",
428+ theme: func() Theme { t := base; t.SchemeKnown = true; return t }(),
429+ wantIndexed: ansiBrightBlue,
430+ },
431+ }
432+
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ styles := NewStyles(tt.theme)
436+
437+ for name, style := range map[string]renderer.Style{
438+ "border": styles.Border,
439+ "muted text": styles.PaletteDesc,
440+ } {
441+ require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name)
442+ if tt.wantIndexed >= 0 {
443+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
444+ require.Equal(t, tt.wantIndexed, style.Foreground, name)
445+ } else {
446+ require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
447+ require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
448+ }
449+ }
450+
451+ status := StatusBarStyle(tt.theme)
452+ accent := AccentStyle(tt.theme)
453+ for name, style := range map[string]renderer.Style{
454+ "title": styles.TabTitle,
455+ "active title": styles.TabTitleActive,
456+ } {
457+ wantBackground := status.BackgroundRGB
458+ if name == "active title" {
459+ wantBackground = accent.BackgroundRGB
460+ }
461+ require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name)
462+ require.Equal(t, wantBackground, style.BackgroundRGB, name)
463+ if tt.wantIndexed >= 0 {
464+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
465+ require.Equal(t, tt.wantIndexed, style.Foreground, name)
466+ } else {
467+ require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
468+ require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
469+ }
470+ }
471+ })
472+ }
473+}
474+
475+func TestPaletteIndexedForegroundFallback(t *testing.T) {
476+ base := Theme{UsePalette: true, SchemeKnown: true, Known: true}
477+
478+ tests := []struct {
479+ name string
480+ theme Theme
481+ want int
482+ ok bool
483+ }{
484+ {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true},
485+ {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true},
486+ {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}},
487+ {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}},
488+ }
489+
490+ for _, tt := range tests {
491+ t.Run(tt.name, func(t *testing.T) {
492+ styles := NewStyles(tt.theme)
493+ baselineTheme := tt.theme
494+ baselineTheme.UsePalette = false
495+ baseline := NewStyles(baselineTheme)
496+ for name, style := range map[string]renderer.Style{
497+ "title": styles.TabTitle,
498+ "active title": styles.TabTitleActive,
499+ "border": styles.Border,
500+ "muted text": styles.PaletteDesc,
501+ } {
502+ require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name)
503+ if tt.ok {
504+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
505+ require.Equal(t, tt.want, style.Foreground, name)
506+ } else {
507+ want := map[string]renderer.Style{
508+ "title": baseline.TabTitle, "active title": baseline.TabTitleActive,
509+ "border": baseline.Border, "muted text": baseline.PaletteDesc,
510+ }[name]
511+ require.Equal(t, want, style, name)
512+ }
513+ }
514+ })
515+ }
516+}
517+
518+func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) {
519+ theme := Theme{
520+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
521+ Background: renderer.RGB{R: 30, G: 30, B: 30},
522+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
523+ UsePalette: true, SchemeKnown: true,
524+ }
525+ styles := NewStyles(theme)
526+ for name, style := range map[string]renderer.Style{
527+ "title": styles.TabTitle, "active title": styles.TabTitleActive,
528+ "border": styles.Border, "muted text": styles.PaletteDesc,
529+ } {
530+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
531+ require.Equal(t, ansiBrightBlue, style.Foreground, name)
532+ require.Equal(t, -1, style.Background, "%s must not use an indexed background", name)
533+ }
534+}
535+
536+func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) {
537+ palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}}
538+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
539+ withDisabledPalette := usableTheme
540+ withDisabledPalette.Palette = palette
541+ withDisabledPalette.PaletteKnown = 1<<4 | 1<<12
542+ withDisabledPalette.UsePalette = false
543+
544+ require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette))
545+
546+ forced := Theme{Known: true, SchemeKnown: true, UsePalette: false}
547+ require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced))
548+}
549+
550+func TestNewStylesComposesSemanticStyles(t *testing.T) {
551+ theme := Theme{
552+ Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
553+ Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e},
554+ HasFG: true,
555+ HasBG: true,
556+ Known: true,
557+ TrueColor: true,
558+ }
559+
560+ styles := NewStyles(theme)
561+ statusBar := StatusBarStyle(theme)
562+ accent := AccentStyle(theme)
563+ selection := SelectionStyle(theme)
564+
565+ require.True(t, styles.StatusBar.Equal(statusBar))
566+ require.True(t, styles.Accent.Equal(accent))
567+ require.True(t, styles.Border.Equal(BorderStyle(theme)))
568+ require.True(t, styles.Selection.Equal(selection))
569+ require.True(t, styles.CopyStatus.Equal(selection))
570+ require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme)))
571+ require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme)))
572+ require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme)))
573+ require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme)))
574+ require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
575+ require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme)))
576+ require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme)))
577+ require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme)))
578+ require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme)))
579+}
@@ -0,0 +1,579 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRelativeLuminance(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ color renderer.RGB
14+ want float64
15+ }{
16+ {name: "black", color: renderer.RGB{}, want: 0},
17+ {name: "white", color: renderer.RGB{R: 255, G: 255, B: 255}, want: 1},
18+ {name: "sRGB linearization breakpoint", color: renderer.RGB{R: 10}, want: 0.2126 * (10.0 / 255.0 / 12.92)},
19+ {name: "nonlinear sRGB", color: renderer.RGB{R: 128, G: 128, B: 128}, want: 0.21586050011389923},
20+ {name: "red", color: renderer.RGB{R: 255}, want: 0.2126},
21+ {name: "green", color: renderer.RGB{G: 255}, want: 0.7152},
22+ {name: "blue", color: renderer.RGB{B: 255}, want: 0.0722},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got := RelativeLuminance(tt.color)
28+ require.InDelta(t, tt.want, got, 1e-12)
29+ require.Equal(t, got, RelativeLuminance(tt.color))
30+ })
31+ }
32+}
33+
34+func TestContrastRatio(t *testing.T) {
35+ black := renderer.RGB{}
36+ white := renderer.RGB{R: 255, G: 255, B: 255}
37+ middle := renderer.RGB{R: 123, G: 45, B: 67}
38+
39+ require.InDelta(t, 21, ContrastRatio(black, white), 1e-12)
40+ require.InDelta(t, 1, ContrastRatio(middle, middle), 1e-12)
41+ require.Equal(t, ContrastRatio(black, middle), ContrastRatio(middle, black))
42+}
43+
44+func TestAccentContrastMinimum(t *testing.T) {
45+ require.Equal(t, 3.0, accentContrastMin)
46+}
47+
48+func TestPulseColor(t *testing.T) {
49+ rgbBase := renderer.Style{
50+ Italic: true,
51+ Foreground: 7,
52+ Background: 8,
53+ HasForegroundRGB: true,
54+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
55+ HasBackgroundRGB: true,
56+ BackgroundRGB: renderer.RGB{R: 20, G: 40, B: 60},
57+ HasUnderlineColor: true,
58+ UnderlineColor: 4,
59+ HasUnderlineColorRGB: false,
60+ }
61+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
62+ mixedBase := rgbBase
63+ mixedBase.HasBackgroundRGB = false
64+
65+ tests := []struct {
66+ name string
67+ base renderer.Style
68+ intensity float64
69+ want renderer.Style
70+ }{
71+ {
72+ name: "RGB starts at the background",
73+ base: rgbBase,
74+ intensity: 0,
75+ want: func() renderer.Style {
76+ style := rgbBase
77+ style.Bold = true
78+ style.ForegroundRGB = renderer.RGB{R: 20, G: 40, B: 60}
79+ return style
80+ }(),
81+ },
82+ {
83+ name: "RGB blends halfway with rounding",
84+ base: rgbBase,
85+ intensity: 0.5,
86+ want: func() renderer.Style {
87+ style := rgbBase
88+ style.Bold = true
89+ style.ForegroundRGB = renderer.RGB{R: 110, G: 70, B: 55}
90+ return style
91+ }(),
92+ },
93+ {
94+ name: "RGB reaches the foreground",
95+ base: rgbBase,
96+ intensity: 1,
97+ want: func() renderer.Style {
98+ style := rgbBase
99+ style.Bold = true
100+ return style
101+ }(),
102+ },
103+ {
104+ name: "RGB foreground without RGB background uses indexed grayscale",
105+ base: mixedBase,
106+ intensity: 0.5,
107+ want: func() renderer.Style {
108+ style := mixedBase
109+ style.Bold = true
110+ style.HasForegroundRGB = false
111+ style.Foreground = 249
112+ return style
113+ }(),
114+ },
115+ {
116+ name: "indexed color uses xterm grayscale start",
117+ base: indexedBase,
118+ intensity: 0,
119+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 244, Background: -1},
120+ },
121+ {
122+ name: "indexed color truncates the grayscale ramp",
123+ base: indexedBase,
124+ intensity: 0.5,
125+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 249, Background: -1},
126+ },
127+ {
128+ name: "indexed color reaches xterm grayscale end",
129+ base: indexedBase,
130+ intensity: 1,
131+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 255, Background: -1},
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ require.Equal(t, tt.want, PulseColor(tt.base, tt.intensity))
138+ })
139+ }
140+}
141+
142+func TestMRUFade(t *testing.T) {
143+ base := renderer.Style{
144+ Bold: true,
145+ Foreground: 7,
146+ Background: 8,
147+ HasForegroundRGB: true,
148+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
149+ HasBackgroundRGB: true,
150+ BackgroundRGB: renderer.RGB{R: 40, G: 60, B: 80},
151+ HasUnderlineColorRGB: true,
152+ UnderlineColorRGB: renderer.RGB{R: 1, G: 2, B: 3},
153+ }
154+ terminalTheme := Theme{HasBG: true, Background: renderer.RGB{R: 10, G: 20, B: 30}}
155+
156+ tests := []struct {
157+ name string
158+ base renderer.Style
159+ theme Theme
160+ i int
161+ count int
162+ want renderer.Style
163+ }{
164+ {name: "single entry is unchanged", base: base, theme: terminalTheme, count: 1, want: base},
165+ {name: "missing theme background is unchanged", base: base, theme: Theme{}, i: 1, count: 3, want: base},
166+ {name: "indexed base is unchanged", base: renderer.DefaultStyle(), theme: terminalTheme, i: 1, count: 3, want: renderer.DefaultStyle()},
167+ {name: "newest entry starts unfaded", base: base, theme: terminalTheme, count: 3, want: base},
168+ {
169+ name: "middle entry fades thirty percent toward terminal background",
170+ base: base,
171+ theme: terminalTheme,
172+ i: 1,
173+ count: 3,
174+ want: func() renderer.Style {
175+ style := base
176+ style.ForegroundRGB = renderer.RGB{R: 143, G: 76, B: 44}
177+ style.BackgroundRGB = renderer.RGB{R: 31, G: 48, B: 65}
178+ return style
179+ }(),
180+ },
181+ {
182+ name: "oldest entry fades sixty percent toward terminal background",
183+ base: base,
184+ theme: terminalTheme,
185+ i: 2,
186+ count: 3,
187+ want: func() renderer.Style {
188+ style := base
189+ style.ForegroundRGB = renderer.RGB{R: 86, G: 52, B: 38}
190+ style.BackgroundRGB = renderer.RGB{R: 22, G: 36, B: 50}
191+ return style
192+ }(),
193+ },
194+ }
195+
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ require.Equal(t, tt.want, MRUFade(tt.base, tt.theme, tt.i, tt.count))
199+ })
200+ }
201+}
202+
203+func TestThemePaletteColor(t *testing.T) {
204+ palette := [16]renderer.RGB{
205+ 3: {R: 0x11, G: 0x22, B: 0x33},
206+ 7: {R: 0xaa, G: 0xbb, B: 0xcc},
207+ }
208+ theme := Theme{
209+ UsePalette: true,
210+ Palette: palette,
211+ PaletteKnown: 1<<3 | 1<<7,
212+ }
213+
214+ tests := []struct {
215+ name string
216+ slot int
217+ want renderer.RGB
218+ ok bool
219+ }{
220+ {name: "known slot", slot: 3, want: palette[3], ok: true},
221+ {name: "unknown slot", slot: 4},
222+ {name: "negative slot", slot: -1},
223+ {name: "slot above palette", slot: 16},
224+ }
225+
226+ for _, tt := range tests {
227+ t.Run(tt.name, func(t *testing.T) {
228+ got, ok := theme.PaletteColor(tt.slot)
229+ require.Equal(t, tt.ok, ok)
230+ require.Equal(t, tt.want, got)
231+ })
232+ }
233+
234+ theme.UsePalette = false
235+ got, ok := theme.PaletteColor(3)
236+ require.False(t, ok)
237+ require.Equal(t, renderer.RGB{}, got)
238+}
239+
240+func TestBuiltinThemesDoNotUsePalette(t *testing.T) {
241+ for name, theme := range map[string]Theme{
242+ "dark": BuiltinDark,
243+ "light": BuiltinLight,
244+ } {
245+ t.Run(name, func(t *testing.T) {
246+ require.False(t, theme.UsePalette)
247+ require.Zero(t, theme.PaletteKnown)
248+ require.Equal(t, [16]renderer.RGB{}, theme.Palette)
249+ })
250+ }
251+}
252+
253+func TestThemeIsComparable(t *testing.T) {
254+ themes := map[Theme]bool{BuiltinDark: true}
255+ require.True(t, themes[BuiltinDark])
256+}
257+
258+func TestPaletteAccentBackground(t *testing.T) {
259+ base := Theme{
260+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
261+ Background: renderer.RGB{R: 100, G: 100, B: 100},
262+ HasFG: true,
263+ HasBG: true,
264+ Known: true,
265+ TrueColor: true,
266+ UsePalette: true,
267+ }
268+ neutral := Blend(base.Background, base.Foreground, accentBlend)
269+
270+ tests := []struct {
271+ name string
272+ palette [16]renderer.RGB
273+ known uint16
274+ usePalette bool
275+ want renderer.RGB
276+ wantContrastMin bool
277+ }{
278+ {
279+ name: "slot 4 supplies a contrasting accent background",
280+ palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}},
281+ known: 1<<4 | 1<<12,
282+ usePalette: true,
283+ want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend),
284+ wantContrastMin: true,
285+ },
286+ {
287+ name: "slot 4 failure escalates to slot 12",
288+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}},
289+ known: 1<<4 | 1<<12,
290+ usePalette: true,
291+ want: Blend(base.Background, renderer.RGB{}, accentBlend),
292+ wantContrastMin: true,
293+ },
294+ {
295+ name: "both palette blues failing uses exact neutral blend",
296+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
297+ known: 1<<4 | 1<<12,
298+ usePalette: true,
299+ want: neutral,
300+ },
301+ {
302+ name: "missing palette blues uses exact neutral blend",
303+ usePalette: true,
304+ want: neutral,
305+ },
306+ {
307+ name: "palette disabled preserves neutral accent",
308+ palette: [16]renderer.RGB{4: {B: 255}},
309+ known: 1 << 4,
310+ usePalette: false,
311+ want: neutral,
312+ },
313+ }
314+
315+ for _, tt := range tests {
316+ t.Run(tt.name, func(t *testing.T) {
317+ theme := base
318+ theme.Palette = tt.palette
319+ theme.PaletteKnown = tt.known
320+ theme.UsePalette = tt.usePalette
321+
322+ got := AccentStyle(theme)
323+ require.True(t, got.HasBackgroundRGB)
324+ require.Equal(t, tt.want, got.BackgroundRGB)
325+ if tt.wantContrastMin {
326+ require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin)
327+ }
328+ })
329+ }
330+}
331+
332+func TestPaletteTitleHueDerivation(t *testing.T) {
333+ base := Theme{
334+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
335+ Background: renderer.RGB{R: 100, G: 100, B: 100},
336+ HasFG: true,
337+ HasBG: true,
338+ Known: true,
339+ TrueColor: true,
340+ UsePalette: true,
341+ }
342+
343+ tests := []struct {
344+ name string
345+ theme Theme
346+ palette [16]renderer.RGB
347+ known uint16
348+ }{
349+ {
350+ name: "slot 4 is used when it contrasts for each title",
351+ theme: Theme{
352+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
353+ Background: renderer.RGB{R: 50, G: 50, B: 50},
354+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
355+ },
356+ palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}},
357+
358+ known: 1<<4 | 1<<12,
359+ },
360+ {
361+ name: "each title escalates independently from slot 4 to slot 12",
362+ palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}},
363+ known: 1<<4 | 1<<12,
364+ },
365+ {
366+ name: "both title hues failing retains neutral muted styles",
367+ palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
368+ known: 1<<4 | 1<<12,
369+ },
370+ }
371+
372+ for _, tt := range tests {
373+ t.Run(tt.name, func(t *testing.T) {
374+ theme := base
375+ if tt.theme.Known {
376+ theme = tt.theme
377+ }
378+ theme.Palette = tt.palette
379+ theme.PaletteKnown = tt.known
380+ styles := NewStyles(theme)
381+ status := StatusBarStyle(theme)
382+ accent := AccentStyle(theme)
383+
384+ if tt.name == "both title hues failing retains neutral muted styles" {
385+ require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme)))
386+ require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
387+ return
388+ }
389+
390+ require.True(t, styles.TabTitle.HasForegroundRGB)
391+ require.True(t, styles.TabTitleActive.HasForegroundRGB)
392+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin)
393+ require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin)
394+
395+ if tt.name == "slot 4 is used when it contrasts for each title" {
396+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB)
397+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
398+ }
399+ if tt.name == "each title escalates independently from slot 4 to slot 12" {
400+ require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB)
401+ require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
402+ }
403+ })
404+ }
405+}
406+
407+func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) {
408+ base := Theme{
409+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
410+ Background: renderer.RGB{R: 50, G: 50, B: 50},
411+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
412+ }
413+
414+ tests := []struct {
415+ name string
416+ theme Theme
417+ wantForeground renderer.RGB
418+ wantIndexed int
419+ }{
420+ {
421+ name: "RGB palette foreground",
422+ theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(),
423+ wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend),
424+ wantIndexed: -1,
425+ },
426+ {
427+ name: "indexed foreground fallback",
428+ theme: func() Theme { t := base; t.SchemeKnown = true; return t }(),
429+ wantIndexed: ansiBrightBlue,
430+ },
431+ }
432+
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ styles := NewStyles(tt.theme)
436+
437+ for name, style := range map[string]renderer.Style{
438+ "border": styles.Border,
439+ "muted text": styles.PaletteDesc,
440+ } {
441+ require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name)
442+ if tt.wantIndexed >= 0 {
443+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
444+ require.Equal(t, tt.wantIndexed, style.Foreground, name)
445+ } else {
446+ require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
447+ require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
448+ }
449+ }
450+
451+ status := StatusBarStyle(tt.theme)
452+ accent := AccentStyle(tt.theme)
453+ for name, style := range map[string]renderer.Style{
454+ "title": styles.TabTitle,
455+ "active title": styles.TabTitleActive,
456+ } {
457+ wantBackground := status.BackgroundRGB
458+ if name == "active title" {
459+ wantBackground = accent.BackgroundRGB
460+ }
461+ require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name)
462+ require.Equal(t, wantBackground, style.BackgroundRGB, name)
463+ if tt.wantIndexed >= 0 {
464+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
465+ require.Equal(t, tt.wantIndexed, style.Foreground, name)
466+ } else {
467+ require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
468+ require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
469+ }
470+ }
471+ })
472+ }
473+}
474+
475+func TestPaletteIndexedForegroundFallback(t *testing.T) {
476+ base := Theme{UsePalette: true, SchemeKnown: true, Known: true}
477+
478+ tests := []struct {
479+ name string
480+ theme Theme
481+ want int
482+ ok bool
483+ }{
484+ {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true},
485+ {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true},
486+ {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}},
487+ {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}},
488+ }
489+
490+ for _, tt := range tests {
491+ t.Run(tt.name, func(t *testing.T) {
492+ styles := NewStyles(tt.theme)
493+ baselineTheme := tt.theme
494+ baselineTheme.UsePalette = false
495+ baseline := NewStyles(baselineTheme)
496+ for name, style := range map[string]renderer.Style{
497+ "title": styles.TabTitle,
498+ "active title": styles.TabTitleActive,
499+ "border": styles.Border,
500+ "muted text": styles.PaletteDesc,
501+ } {
502+ require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name)
503+ if tt.ok {
504+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
505+ require.Equal(t, tt.want, style.Foreground, name)
506+ } else {
507+ want := map[string]renderer.Style{
508+ "title": baseline.TabTitle, "active title": baseline.TabTitleActive,
509+ "border": baseline.Border, "muted text": baseline.PaletteDesc,
510+ }[name]
511+ require.Equal(t, want, style, name)
512+ }
513+ }
514+ })
515+ }
516+}
517+
518+func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) {
519+ theme := Theme{
520+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
521+ Background: renderer.RGB{R: 30, G: 30, B: 30},
522+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
523+ UsePalette: true, SchemeKnown: true,
524+ }
525+ styles := NewStyles(theme)
526+ for name, style := range map[string]renderer.Style{
527+ "title": styles.TabTitle, "active title": styles.TabTitleActive,
528+ "border": styles.Border, "muted text": styles.PaletteDesc,
529+ } {
530+ require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
531+ require.Equal(t, ansiBrightBlue, style.Foreground, name)
532+ require.Equal(t, -1, style.Background, "%s must not use an indexed background", name)
533+ }
534+}
535+
536+func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) {
537+ palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}}
538+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
539+ withDisabledPalette := usableTheme
540+ withDisabledPalette.Palette = palette
541+ withDisabledPalette.PaletteKnown = 1<<4 | 1<<12
542+ withDisabledPalette.UsePalette = false
543+
544+ require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette))
545+
546+ forced := Theme{Known: true, SchemeKnown: true, UsePalette: false}
547+ require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced))
548+}
549+
550+func TestNewStylesComposesSemanticStyles(t *testing.T) {
551+ theme := Theme{
552+ Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
553+ Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e},
554+ HasFG: true,
555+ HasBG: true,
556+ Known: true,
557+ TrueColor: true,
558+ }
559+
560+ styles := NewStyles(theme)
561+ statusBar := StatusBarStyle(theme)
562+ accent := AccentStyle(theme)
563+ selection := SelectionStyle(theme)
564+
565+ require.True(t, styles.StatusBar.Equal(statusBar))
566+ require.True(t, styles.Accent.Equal(accent))
567+ require.True(t, styles.Border.Equal(BorderStyle(theme)))
568+ require.True(t, styles.Selection.Equal(selection))
569+ require.True(t, styles.CopyStatus.Equal(selection))
570+ require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme)))
571+ require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme)))
572+ require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme)))
573+ require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme)))
574+ require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
575+ require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme)))
576+ require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme)))
577+ require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme)))
578+ require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme)))
579+}
M internal/usecase/theme/theme.go +218 -64
@@ -11,16 +11,28 @@ import (
1111
1212 const maxPending = 64
1313
14-// Theme describes terminal default colors reported by OSC 10/11.
14+// Theme describes terminal default colors and ANSI palette entries.
1515 type Theme struct {
16- Foreground renderer.RGB
17- Background renderer.RGB
18- HasFG bool
19- HasBG bool
20- TrueColor bool
21- Known bool
22- SchemeKnown bool
23- Light bool
16+ Foreground renderer.RGB
17+ Background renderer.RGB
18+ Palette [16]renderer.RGB
19+ PaletteKnown uint16
20+ HasFG bool
21+ HasBG bool
22+ TrueColor bool
23+ Known bool
24+ SchemeKnown bool
25+ Light bool
26+ UsePalette bool
27+}
28+
29+// PaletteColor returns a palette color only when palette inheritance is
30+// enabled and the terminal reported the requested ANSI slot.
31+func (t Theme) PaletteColor(slot int) (renderer.RGB, bool) {
32+ if !t.UsePalette || slot < 0 || slot >= len(t.Palette) || t.PaletteKnown&(uint16(1)<<slot) == 0 {
33+ return renderer.RGB{}, false
34+ }
35+ return t.Palette[slot], true
2436 }
2537
2638 var (
@@ -74,32 +86,31 @@ func ParseXColor(s string) (renderer.RGB, bool) {
7486 if len(parts) != 3 {
7587 return renderer.RGB{}, false
7688 }
77- width := len(parts[0])
78- if width != 2 && width != 4 {
79- return renderer.RGB{}, false
80- }
8189 var out [3]uint8
8290 for i, part := range parts {
83- if len(part) != width {
84- return renderer.RGB{}, false
85- }
86- if width == 2 {
87- v, ok := parseHexByte(part)
88- if !ok {
89- return renderer.RGB{}, false
90- }
91- out[i] = v
92- continue
93- }
94- v, err := strconv.ParseUint(part, 16, 16)
95- if err != nil {
91+ component, ok := parseXColorComponent(part)
92+ if !ok {
9693 return renderer.RGB{}, false
9794 }
98- out[i] = uint8(v >> 8)
95+ out[i] = component
9996 }
10097 return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true
10198 }
10299
100+// parseXColorComponent scales an XParseColor component of one to four hex
101+// digits to an 8-bit channel.
102+func parseXColorComponent(s string) (uint8, bool) {
103+ if len(s) < 1 || len(s) > 4 {
104+ return 0, false
105+ }
106+ value, err := strconv.ParseUint(s, 16, 16)
107+ if err != nil {
108+ return 0, false
109+ }
110+ maxValue := uint64((1 << (4 * len(s))) - 1)
111+ return uint8(value * 255 / maxValue), true
112+}
113+
103114 func parseHexByte(s string) (uint8, bool) {
104115 v, err := strconv.ParseUint(s, 16, 8)
105116 if err != nil {
@@ -108,12 +119,13 @@ func parseHexByte(s string) (uint8, bool) {
108119 return uint8(v), true
109120 }
110121
111-// Scanner decodes OSC 10/11 color responses while preserving ordinary bytes.
122+// Scanner decodes terminal color responses while preserving ordinary bytes.
112123 type Scanner struct {
113124 pending []byte
114125 }
115126
116-// Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.
127+// Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
128+// responses terminated by BEL or ST.
117129 // All non-matching bytes are emitted through onBytes in original order, and a
118130 // contiguous run of ordinary bytes (including any ESC that does not start a
119131 // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
@@ -121,7 +133,7 @@ type Scanner struct {
121133 // callers never see it split. Partial OSC color responses are buffered across
122134 // calls, but the buffer is bounded so an unterminated OSC cannot block
123135 // unrelated input forever.
124-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
136+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onPalette func(slot int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
125137 if len(s.pending) > 0 {
126138 combined := make([]byte, 0, len(s.pending)+len(data))
127139 combined = append(combined, s.pending...)
@@ -152,7 +164,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
152164 return
153165 }
154166
155- completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
167+ kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
156168 if !completePrefix {
157169 if possiblePrefix {
158170 if byteStart < i {
@@ -173,20 +185,30 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
173185 onBytes(data[byteStart:i])
174186 }
175187
176- termStart, termEnd := findTerminator(data[i+5:])
188+ termStart, termEnd := findTerminator(data[i+prefixLen:])
177189 if termStart < 0 {
178190 s.bufferOrFlush(data[i:], onBytes)
179191 return
180192 }
181- termStart += i + 5
182- termEnd += i + 5
193+ termStart += i + prefixLen
194+ termEnd += i + prefixLen
183195 raw := data[i:termEnd]
184- kind := int(data[i+3]-'0') + 10
185- rgb, ok := ParseXColor(string(data[i+5 : termStart]))
186- if ok {
187- onColor(kind, rgb)
188- } else {
189- onBytes(raw)
196+ body := string(data[i+prefixLen : termStart])
197+ switch kind {
198+ case 4:
199+ slot, rgb, ok := parsePaletteResponse(body)
200+ if ok {
201+ onPalette(slot, rgb)
202+ } else {
203+ onBytes(raw)
204+ }
205+ default:
206+ rgb, ok := ParseXColor(body)
207+ if ok {
208+ onColor(kind, rgb)
209+ } else {
210+ onBytes(raw)
211+ }
190212 }
191213 i = termEnd - 1
192214 byteStart = termEnd
@@ -206,23 +228,52 @@ func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
206228 s.pending = append(s.pending, partial...)
207229 }
208230
209-func colorOSCPrefix(data []byte) (complete bool, possible bool) {
231+func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) {
210232 if len(data) < 2 {
211- return false, false
212- }
213- prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")}
214- for _, prefix := range prefixes {
215- if len(data) >= len(prefix) {
216- if bytes.Equal(data[:len(prefix)], prefix) {
217- return true, true
233+ return 0, 0, false, false
234+ }
235+ prefixes := []struct {
236+ kind int
237+ prefix []byte
238+ }{
239+ {kind: 10, prefix: []byte("\x1b]10;")},
240+ {kind: 11, prefix: []byte("\x1b]11;")},
241+ {kind: 4, prefix: []byte("\x1b]4;")},
242+ }
243+ for _, candidate := range prefixes {
244+ if len(data) >= len(candidate.prefix) {
245+ if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) {
246+ return candidate.kind, len(candidate.prefix), true, true
218247 }
219248 continue
220249 }
221- if bytes.Equal(data, prefix[:len(data)]) {
222- return false, true
250+ if bytes.Equal(data, candidate.prefix[:len(data)]) {
251+ return candidate.kind, len(candidate.prefix), false, true
252+ }
253+ }
254+ return 0, 0, false, false
255+}
256+
257+func parsePaletteResponse(body string) (int, renderer.RGB, bool) {
258+ indexEnd := strings.IndexByte(body, ';')
259+ if indexEnd < 1 {
260+ return 0, renderer.RGB{}, false
261+ }
262+ slot := 0
263+ for _, b := range []byte(body[:indexEnd]) {
264+ if b < '0' || b > '9' {
265+ return 0, renderer.RGB{}, false
266+ }
267+ slot = slot*10 + int(b-'0')
268+ if slot > 15 {
269+ return 0, renderer.RGB{}, false
223270 }
224271 }
225- return false, false
272+ rgb, ok := ParseXColor(body[indexEnd+1:])
273+ if !ok {
274+ return 0, renderer.RGB{}, false
275+ }
276+ return slot, rgb, true
226277 }
227278
228279 // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
@@ -305,6 +356,13 @@ func StatusBarStyle(t Theme) renderer.Style {
305356 return style
306357 }
307358
359+const (
360+ ansiBlue = 4
361+ ansiBrightBlue = 12
362+ accentBlend = 0.25
363+ titleHueBlend = 0.2
364+)
365+
308366 func AccentStyle(t Theme) renderer.Style {
309367 if !usable(t) {
310368 return inverseStyle()
@@ -313,28 +371,69 @@ func AccentStyle(t Theme) renderer.Style {
313371 style.HasForegroundRGB = true
314372 style.ForegroundRGB = t.Foreground
315373 style.HasBackgroundRGB = true
316- style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25)
374+ style.BackgroundRGB = accentBackground(t)
317375 return style
318376 }
319377
378+// accentBackground uses a terminal's blue palette only when the resulting
379+// foreground/background pair remains legible. A neutral blend is always the
380+// fallback, including when palette inheritance is disabled.
381+func accentBackground(t Theme) renderer.RGB {
382+ neutral := Blend(t.Background, t.Foreground, accentBlend)
383+ if !t.UsePalette {
384+ return neutral
385+ }
386+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
387+ blue, ok := t.PaletteColor(slot)
388+ if !ok {
389+ continue
390+ }
391+ candidate := Blend(t.Background, blue, accentBlend)
392+ if ContrastRatio(t.Foreground, candidate) >= accentContrastMin {
393+ return candidate
394+ }
395+ }
396+ return neutral
397+}
398+
320399 func BorderStyle(t Theme) renderer.Style {
321- if !usable(t) {
322- return renderer.DefaultStyle()
400+ neutral := renderer.DefaultStyle()
401+ if usable(t) {
402+ neutral.HasForegroundRGB = true
403+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
323404 }
324- style := renderer.DefaultStyle()
325- style.HasForegroundRGB = true
326- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
327- return style
405+ if !t.UsePalette {
406+ return neutral
407+ }
408+
409+ base := renderer.DefaultStyle()
410+ if usable(t) {
411+ base.HasForegroundRGB = true
412+ base.ForegroundRGB = t.Foreground
413+ base.HasBackgroundRGB = true
414+ base.BackgroundRGB = t.Background
415+ }
416+ return paletteForegroundStyle(base, neutral, t)
328417 }
329418
330419 func MutedTextStyle(t Theme) renderer.Style {
331- if !usable(t) {
332- return renderer.DefaultStyle()
420+ neutral := renderer.DefaultStyle()
421+ if usable(t) {
422+ neutral.HasForegroundRGB = true
423+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
333424 }
334- style := renderer.DefaultStyle()
335- style.HasForegroundRGB = true
336- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
337- return style
425+ if !t.UsePalette {
426+ return neutral
427+ }
428+
429+ base := renderer.DefaultStyle()
430+ if usable(t) {
431+ base.HasForegroundRGB = true
432+ base.ForegroundRGB = t.Foreground
433+ base.HasBackgroundRGB = true
434+ base.BackgroundRGB = t.Background
435+ }
436+ return paletteForegroundStyle(base, neutral, t)
338437 }
339438
340439 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
@@ -373,6 +472,61 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
373472 return out
374473 }
375474
475+// PaletteMutedVariantStyle derives a title foreground from a terminal blue
476+// palette entry. Its contrast is evaluated against the title's actual base
477+// background, rather than the terminal background, because status and active
478+// title backgrounds differ.
479+func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
480+ return paletteForegroundStyle(base, MutedVariantStyle(base, t), t)
481+}
482+
483+// paletteForegroundStyle uses palette blues for a foreground only. It never
484+// changes a background, so palette inheritance cannot introduce indexed
485+// backgrounds. If terminal RGB data is unavailable, a known color scheme can
486+// still select a best-effort ANSI foreground. A reported blue that fails
487+// contrast deliberately suppresses that indexed fallback.
488+func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style {
489+ if !t.UsePalette {
490+ return neutral
491+ }
492+
493+ knownBlue := paletteBlueKnown(t)
494+ if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB {
495+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
496+ blue, ok := t.PaletteColor(slot)
497+ if !ok {
498+ continue
499+ }
500+ candidate := Blend(base.ForegroundRGB, blue, titleHueBlend)
501+ if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin {
502+ out := neutral
503+ out.HasForegroundRGB = true
504+ out.ForegroundRGB = candidate
505+ return out
506+ }
507+ }
508+ }
509+
510+ if !knownBlue && t.SchemeKnown {
511+ out := neutral
512+ out.HasForegroundRGB = false
513+ if t.Light {
514+ out.Foreground = ansiBlue
515+ } else {
516+ out.Foreground = ansiBrightBlue
517+ }
518+ return out
519+ }
520+ return neutral
521+}
522+
523+func paletteBlueKnown(t Theme) bool {
524+ if !t.UsePalette {
525+ return false
526+ }
527+ return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0
528+}
529+
376530 const defaultDimmingPercent = 35
377531
378532 // Dimmer transforms resolved terminal colors for subdued UI states. Construct
@@ -11,16 +11,28 @@ import (
11 11
12 const maxPending = 6412 const maxPending = 64
13 13
14-// Theme describes terminal default colors reported by OSC 10/11.14+// Theme describes terminal default colors and ANSI palette entries.
15 type Theme struct {15 type Theme struct {
16- Foreground renderer.RGB16+ Foreground renderer.RGB
17- Background renderer.RGB17+ Background renderer.RGB
18- HasFG bool18+ Palette [16]renderer.RGB
19- HasBG bool19+ PaletteKnown uint16
20- TrueColor bool20+ HasFG bool
21- Known bool21+ HasBG bool
22- SchemeKnown bool22+ TrueColor bool
23- Light bool23+ Known bool
24+ SchemeKnown bool
25+ Light bool
26+ UsePalette bool
27+}
28+
29+// PaletteColor returns a palette color only when palette inheritance is
30+// enabled and the terminal reported the requested ANSI slot.
31+func (t Theme) PaletteColor(slot int) (renderer.RGB, bool) {
32+ if !t.UsePalette || slot < 0 || slot >= len(t.Palette) || t.PaletteKnown&(uint16(1)<<slot) == 0 {
33+ return renderer.RGB{}, false
34+ }
35+ return t.Palette[slot], true
24 }36 }
25 37
26 var (38 var (
@@ -74,32 +86,31 @@ func ParseXColor(s string) (renderer.RGB, bool) {
74 if len(parts) != 3 {86 if len(parts) != 3 {
75 return renderer.RGB{}, false87 return renderer.RGB{}, false
76 }88 }
77- width := len(parts[0])
78- if width != 2 && width != 4 {
79- return renderer.RGB{}, false
80- }
81 var out [3]uint889 var out [3]uint8
82 for i, part := range parts {90 for i, part := range parts {
83- if len(part) != width {91+ component, ok := parseXColorComponent(part)
84- return renderer.RGB{}, false92+ if !ok {
85- }
86- if width == 2 {
87- v, ok := parseHexByte(part)
88- if !ok {
89- return renderer.RGB{}, false
90- }
91- out[i] = v
92- continue
93- }
94- v, err := strconv.ParseUint(part, 16, 16)
95- if err != nil {
96 return renderer.RGB{}, false93 return renderer.RGB{}, false
97 }94 }
98- out[i] = uint8(v >> 8)95+ out[i] = component
99 }96 }
100 return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true97 return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true
101 }98 }
102 99
100+// parseXColorComponent scales an XParseColor component of one to four hex
101+// digits to an 8-bit channel.
102+func parseXColorComponent(s string) (uint8, bool) {
103+ if len(s) < 1 || len(s) > 4 {
104+ return 0, false
105+ }
106+ value, err := strconv.ParseUint(s, 16, 16)
107+ if err != nil {
108+ return 0, false
109+ }
110+ maxValue := uint64((1 << (4 * len(s))) - 1)
111+ return uint8(value * 255 / maxValue), true
112+}
113+
103 func parseHexByte(s string) (uint8, bool) {114 func parseHexByte(s string) (uint8, bool) {
104 v, err := strconv.ParseUint(s, 16, 8)115 v, err := strconv.ParseUint(s, 16, 8)
105 if err != nil {116 if err != nil {
@@ -108,12 +119,13 @@ func parseHexByte(s string) (uint8, bool) {
108 return uint8(v), true119 return uint8(v), true
109 }120 }
110 121
111-// Scanner decodes OSC 10/11 color responses while preserving ordinary bytes.122+// Scanner decodes terminal color responses while preserving ordinary bytes.
112 type Scanner struct {123 type Scanner struct {
113 pending []byte124 pending []byte
114 }125 }
115 126
116-// Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.127+// Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
128+// responses terminated by BEL or ST.
117 // All non-matching bytes are emitted through onBytes in original order, and a129 // All non-matching bytes are emitted through onBytes in original order, and a
118 // contiguous run of ordinary bytes (including any ESC that does not start a130 // contiguous run of ordinary bytes (including any ESC that does not start a
119 // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse131 // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
@@ -121,7 +133,7 @@ type Scanner struct {
121 // callers never see it split. Partial OSC color responses are buffered across133 // callers never see it split. Partial OSC color responses are buffered across
122 // calls, but the buffer is bounded so an unterminated OSC cannot block134 // calls, but the buffer is bounded so an unterminated OSC cannot block
123 // unrelated input forever.135 // unrelated input forever.
124-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {136+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onPalette func(slot int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
125 if len(s.pending) > 0 {137 if len(s.pending) > 0 {
126 combined := make([]byte, 0, len(s.pending)+len(data))138 combined := make([]byte, 0, len(s.pending)+len(data))
127 combined = append(combined, s.pending...)139 combined = append(combined, s.pending...)
@@ -152,7 +164,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
152 return164 return
153 }165 }
154 166
155- completePrefix, possiblePrefix := colorOSCPrefix(data[i:])167+ kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
156 if !completePrefix {168 if !completePrefix {
157 if possiblePrefix {169 if possiblePrefix {
158 if byteStart < i {170 if byteStart < i {
@@ -173,20 +185,30 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
173 onBytes(data[byteStart:i])185 onBytes(data[byteStart:i])
174 }186 }
175 187
176- termStart, termEnd := findTerminator(data[i+5:])188+ termStart, termEnd := findTerminator(data[i+prefixLen:])
177 if termStart < 0 {189 if termStart < 0 {
178 s.bufferOrFlush(data[i:], onBytes)190 s.bufferOrFlush(data[i:], onBytes)
179 return191 return
180 }192 }
181- termStart += i + 5193+ termStart += i + prefixLen
182- termEnd += i + 5194+ termEnd += i + prefixLen
183 raw := data[i:termEnd]195 raw := data[i:termEnd]
184- kind := int(data[i+3]-'0') + 10196+ body := string(data[i+prefixLen : termStart])
185- rgb, ok := ParseXColor(string(data[i+5 : termStart]))197+ switch kind {
186- if ok {198+ case 4:
187- onColor(kind, rgb)199+ slot, rgb, ok := parsePaletteResponse(body)
188- } else {200+ if ok {
189- onBytes(raw)201+ onPalette(slot, rgb)
202+ } else {
203+ onBytes(raw)
204+ }
205+ default:
206+ rgb, ok := ParseXColor(body)
207+ if ok {
208+ onColor(kind, rgb)
209+ } else {
210+ onBytes(raw)
211+ }
190 }212 }
191 i = termEnd - 1213 i = termEnd - 1
192 byteStart = termEnd214 byteStart = termEnd
@@ -206,23 +228,52 @@ func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
206 s.pending = append(s.pending, partial...)228 s.pending = append(s.pending, partial...)
207 }229 }
208 230
209-func colorOSCPrefix(data []byte) (complete bool, possible bool) {231+func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) {
210 if len(data) < 2 {232 if len(data) < 2 {
211- return false, false233+ return 0, 0, false, false
212- }234+ }
213- prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")}235+ prefixes := []struct {
214- for _, prefix := range prefixes {236+ kind int
215- if len(data) >= len(prefix) {237+ prefix []byte
216- if bytes.Equal(data[:len(prefix)], prefix) {238+ }{
217- return true, true239+ {kind: 10, prefix: []byte("\x1b]10;")},
240+ {kind: 11, prefix: []byte("\x1b]11;")},
241+ {kind: 4, prefix: []byte("\x1b]4;")},
242+ }
243+ for _, candidate := range prefixes {
244+ if len(data) >= len(candidate.prefix) {
245+ if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) {
246+ return candidate.kind, len(candidate.prefix), true, true
218 }247 }
219 continue248 continue
220 }249 }
221- if bytes.Equal(data, prefix[:len(data)]) {250+ if bytes.Equal(data, candidate.prefix[:len(data)]) {
222- return false, true251+ return candidate.kind, len(candidate.prefix), false, true
252+ }
253+ }
254+ return 0, 0, false, false
255+}
256+
257+func parsePaletteResponse(body string) (int, renderer.RGB, bool) {
258+ indexEnd := strings.IndexByte(body, ';')
259+ if indexEnd < 1 {
260+ return 0, renderer.RGB{}, false
261+ }
262+ slot := 0
263+ for _, b := range []byte(body[:indexEnd]) {
264+ if b < '0' || b > '9' {
265+ return 0, renderer.RGB{}, false
266+ }
267+ slot = slot*10 + int(b-'0')
268+ if slot > 15 {
269+ return 0, renderer.RGB{}, false
223 }270 }
224 }271 }
225- return false, false272+ rgb, ok := ParseXColor(body[indexEnd+1:])
273+ if !ok {
274+ return 0, renderer.RGB{}, false
275+ }
276+ return slot, rgb, true
226 }277 }
227 278
228 // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev279 // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
@@ -305,6 +356,13 @@ func StatusBarStyle(t Theme) renderer.Style {
305 return style356 return style
306 }357 }
307 358
359+const (
360+ ansiBlue = 4
361+ ansiBrightBlue = 12
362+ accentBlend = 0.25
363+ titleHueBlend = 0.2
364+)
365+
308 func AccentStyle(t Theme) renderer.Style {366 func AccentStyle(t Theme) renderer.Style {
309 if !usable(t) {367 if !usable(t) {
310 return inverseStyle()368 return inverseStyle()
@@ -313,28 +371,69 @@ func AccentStyle(t Theme) renderer.Style {
313 style.HasForegroundRGB = true371 style.HasForegroundRGB = true
314 style.ForegroundRGB = t.Foreground372 style.ForegroundRGB = t.Foreground
315 style.HasBackgroundRGB = true373 style.HasBackgroundRGB = true
316- style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25)374+ style.BackgroundRGB = accentBackground(t)
317 return style375 return style
318 }376 }
319 377
378+// accentBackground uses a terminal's blue palette only when the resulting
379+// foreground/background pair remains legible. A neutral blend is always the
380+// fallback, including when palette inheritance is disabled.
381+func accentBackground(t Theme) renderer.RGB {
382+ neutral := Blend(t.Background, t.Foreground, accentBlend)
383+ if !t.UsePalette {
384+ return neutral
385+ }
386+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
387+ blue, ok := t.PaletteColor(slot)
388+ if !ok {
389+ continue
390+ }
391+ candidate := Blend(t.Background, blue, accentBlend)
392+ if ContrastRatio(t.Foreground, candidate) >= accentContrastMin {
393+ return candidate
394+ }
395+ }
396+ return neutral
397+}
398+
320 func BorderStyle(t Theme) renderer.Style {399 func BorderStyle(t Theme) renderer.Style {
321- if !usable(t) {400+ neutral := renderer.DefaultStyle()
322- return renderer.DefaultStyle()401+ if usable(t) {
402+ neutral.HasForegroundRGB = true
403+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
323 }404 }
324- style := renderer.DefaultStyle()405+ if !t.UsePalette {
325- style.HasForegroundRGB = true406+ return neutral
326- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)407+ }
327- return style408+
409+ base := renderer.DefaultStyle()
410+ if usable(t) {
411+ base.HasForegroundRGB = true
412+ base.ForegroundRGB = t.Foreground
413+ base.HasBackgroundRGB = true
414+ base.BackgroundRGB = t.Background
415+ }
416+ return paletteForegroundStyle(base, neutral, t)
328 }417 }
329 418
330 func MutedTextStyle(t Theme) renderer.Style {419 func MutedTextStyle(t Theme) renderer.Style {
331- if !usable(t) {420+ neutral := renderer.DefaultStyle()
332- return renderer.DefaultStyle()421+ if usable(t) {
422+ neutral.HasForegroundRGB = true
423+ neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
333 }424 }
334- style := renderer.DefaultStyle()425+ if !t.UsePalette {
335- style.HasForegroundRGB = true426+ return neutral
336- style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)427+ }
337- return style428+
429+ base := renderer.DefaultStyle()
430+ if usable(t) {
431+ base.HasForegroundRGB = true
432+ base.ForegroundRGB = t.Foreground
433+ base.HasBackgroundRGB = true
434+ base.BackgroundRGB = t.Background
435+ }
436+ return paletteForegroundStyle(base, neutral, t)
338 }437 }
339 438
340 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own439 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
@@ -373,6 +472,61 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
373 return out472 return out
374 }473 }
375 474
475+// PaletteMutedVariantStyle derives a title foreground from a terminal blue
476+// palette entry. Its contrast is evaluated against the title's actual base
477+// background, rather than the terminal background, because status and active
478+// title backgrounds differ.
479+func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
480+ return paletteForegroundStyle(base, MutedVariantStyle(base, t), t)
481+}
482+
483+// paletteForegroundStyle uses palette blues for a foreground only. It never
484+// changes a background, so palette inheritance cannot introduce indexed
485+// backgrounds. If terminal RGB data is unavailable, a known color scheme can
486+// still select a best-effort ANSI foreground. A reported blue that fails
487+// contrast deliberately suppresses that indexed fallback.
488+func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style {
489+ if !t.UsePalette {
490+ return neutral
491+ }
492+
493+ knownBlue := paletteBlueKnown(t)
494+ if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB {
495+ for _, slot := range []int{ansiBlue, ansiBrightBlue} {
496+ blue, ok := t.PaletteColor(slot)
497+ if !ok {
498+ continue
499+ }
500+ candidate := Blend(base.ForegroundRGB, blue, titleHueBlend)
501+ if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin {
502+ out := neutral
503+ out.HasForegroundRGB = true
504+ out.ForegroundRGB = candidate
505+ return out
506+ }
507+ }
508+ }
509+
510+ if !knownBlue && t.SchemeKnown {
511+ out := neutral
512+ out.HasForegroundRGB = false
513+ if t.Light {
514+ out.Foreground = ansiBlue
515+ } else {
516+ out.Foreground = ansiBrightBlue
517+ }
518+ return out
519+ }
520+ return neutral
521+}
522+
523+func paletteBlueKnown(t Theme) bool {
524+ if !t.UsePalette {
525+ return false
526+ }
527+ return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0
528+}
529+
376 const defaultDimmingPercent = 35530 const defaultDimmingPercent = 35
377 531
378 // Dimmer transforms resolved terminal colors for subdued UI states. Construct532 // Dimmer transforms resolved terminal colors for subdued UI states. Construct
M internal/usecase/theme/theme_test.go +20 -15
@@ -16,10 +16,15 @@ func TestParseXColor(t *testing.T) {
1616 want renderer.RGB
1717 ok bool
1818 }{
19- {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
19+ {name: "mixed component widths scale independently", in: "rgb:1/24/86", want: renderer.RGB{R: 0x11, G: 0x24, B: 0x86}, ok: true},
20+ {name: "one, three, and four digit components", in: "rgb:f/abc/1234", want: renderer.RGB{R: 0xff, G: 0xab, B: 0x12}, ok: true},
2021 {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
2122 {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true},
22- {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false},
23+ {name: "empty component rejected", in: "rgb:/24/86", ok: false},
24+ {name: "too-wide component rejected", in: "rgb:12345/24/86", ok: false},
25+ {name: "non-hex component rejected", in: "rgb:1/2g/86", ok: false},
26+ {name: "missing component rejected", in: "rgb:1/24", ok: false},
27+ {name: "extra component rejected", in: "rgb:1/24/86/ff", ok: false},
2328 {name: "garbage rejected", in: "not-a-color", ok: false},
2429 {name: "bad hex rejected", in: "#xyzxyz", ok: false},
2530 }
@@ -50,7 +55,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
5055 kind int
5156 rgb renderer.RGB
5257 }{kind: kind, rgb: rgb})
53- }, func(light bool) {}, func(b []byte) { out.Write(b) })
58+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
5459
5560 if out.String() != "abc" {
5661 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -73,8 +78,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
7378 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
7479 onBytes := func(b []byte) { out.Write(b) }
7580
76- s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)
77- s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)
81+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
82+ s.Scan([]byte("3456\x07y"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
7883
7984 if out.String() != "xy" {
8085 t.Fatalf("passthrough=%q want xy", out.String())
@@ -112,7 +117,7 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
112117 var out bytes.Buffer
113118
114119 for _, chunk := range tt.chunks {
115- s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
120+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
116121 }
117122
118123 if out.String() != tt.wantOut {
@@ -130,7 +135,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
130135 var out bytes.Buffer
131136 schemes := 0
132137
133- s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
138+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
134139
135140 if out.String() != "a\x1b[" {
136141 t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
@@ -140,7 +145,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
140145 }
141146
142147 out.Reset()
143- s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
148+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
144149
145150 if out.String() != "A" {
146151 t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
@@ -152,7 +157,7 @@ func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
152157 var out bytes.Buffer
153158 colors := 0
154159
155- s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
160+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
156161
157162 if colors != 0 {
158163 t.Fatalf("colors=%d want 0", colors)
@@ -168,7 +173,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
168173 var out bytes.Buffer
169174 colors := 0
170175
171- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
176+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
172177
173178 if colors != 0 {
174179 t.Fatalf("colors=%d want 0", colors)
@@ -184,7 +189,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
184189 colors := 0
185190
186191 in := []byte("\x1b[<0;1;1M")
187- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {
192+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
188193 chunks = append(chunks, append([]byte(nil), b...))
189194 })
190195
@@ -204,7 +209,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
204209 var chunks [][]byte
205210
206211 in := []byte("\x1b[A")
207- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
212+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
208213 chunks = append(chunks, append([]byte(nil), b...))
209214 })
210215
@@ -229,7 +234,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
229234 kind int
230235 rgb renderer.RGB
231236 }{kind: kind, rgb: rgb})
232- }, func(light bool) {}, func(b []byte) {
237+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
233238 chunks = append(chunks, append([]byte(nil), b...))
234239 })
235240
@@ -254,8 +259,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
254259 var s Scanner
255260 var out bytes.Buffer
256261
257- s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
258- s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
262+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
263+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
259264
260265 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
261266 t.Fatalf("overflow passthrough=%q", out.String())
@@ -16,10 +16,15 @@ func TestParseXColor(t *testing.T) {
16 want renderer.RGB16 want renderer.RGB
17 ok bool17 ok bool
18 }{18 }{
19- {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},19+ {name: "mixed component widths scale independently", in: "rgb:1/24/86", want: renderer.RGB{R: 0x11, G: 0x24, B: 0x86}, ok: true},
20+ {name: "one, three, and four digit components", in: "rgb:f/abc/1234", want: renderer.RGB{R: 0xff, G: 0xab, B: 0x12}, ok: true},
20 {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},21 {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
21 {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true},22 {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true},
22- {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false},23+ {name: "empty component rejected", in: "rgb:/24/86", ok: false},
24+ {name: "too-wide component rejected", in: "rgb:12345/24/86", ok: false},
25+ {name: "non-hex component rejected", in: "rgb:1/2g/86", ok: false},
26+ {name: "missing component rejected", in: "rgb:1/24", ok: false},
27+ {name: "extra component rejected", in: "rgb:1/24/86/ff", ok: false},
23 {name: "garbage rejected", in: "not-a-color", ok: false},28 {name: "garbage rejected", in: "not-a-color", ok: false},
24 {name: "bad hex rejected", in: "#xyzxyz", ok: false},29 {name: "bad hex rejected", in: "#xyzxyz", ok: false},
25 }30 }
@@ -50,7 +55,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
50 kind int55 kind int
51 rgb renderer.RGB56 rgb renderer.RGB
52 }{kind: kind, rgb: rgb})57 }{kind: kind, rgb: rgb})
53- }, func(light bool) {}, func(b []byte) { out.Write(b) })58+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
54 59
55 if out.String() != "abc" {60 if out.String() != "abc" {
56 t.Fatalf("passthrough=%q want %q", out.String(), "abc")61 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -73,8 +78,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
73 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }78 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
74 onBytes := func(b []byte) { out.Write(b) }79 onBytes := func(b []byte) { out.Write(b) }
75 80
76- s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)81+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
77- s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)82+ s.Scan([]byte("3456\x07y"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
78 83
79 if out.String() != "xy" {84 if out.String() != "xy" {
80 t.Fatalf("passthrough=%q want xy", out.String())85 t.Fatalf("passthrough=%q want xy", out.String())
@@ -112,7 +117,7 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
112 var out bytes.Buffer117 var out bytes.Buffer
113 118
114 for _, chunk := range tt.chunks {119 for _, chunk := range tt.chunks {
115- s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })120+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
116 }121 }
117 122
118 if out.String() != tt.wantOut {123 if out.String() != tt.wantOut {
@@ -130,7 +135,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
130 var out bytes.Buffer135 var out bytes.Buffer
131 schemes := 0136 schemes := 0
132 137
133- s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })138+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
134 139
135 if out.String() != "a\x1b[" {140 if out.String() != "a\x1b[" {
136 t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")141 t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
@@ -140,7 +145,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
140 }145 }
141 146
142 out.Reset()147 out.Reset()
143- s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })148+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
144 149
145 if out.String() != "A" {150 if out.String() != "A" {
146 t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")151 t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
@@ -152,7 +157,7 @@ func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
152 var out bytes.Buffer157 var out bytes.Buffer
153 colors := 0158 colors := 0
154 159
155- s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })160+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
156 161
157 if colors != 0 {162 if colors != 0 {
158 t.Fatalf("colors=%d want 0", colors)163 t.Fatalf("colors=%d want 0", colors)
@@ -168,7 +173,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
168 var out bytes.Buffer173 var out bytes.Buffer
169 colors := 0174 colors := 0
170 175
171- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })176+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
172 177
173 if colors != 0 {178 if colors != 0 {
174 t.Fatalf("colors=%d want 0", colors)179 t.Fatalf("colors=%d want 0", colors)
@@ -184,7 +189,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
184 colors := 0189 colors := 0
185 190
186 in := []byte("\x1b[<0;1;1M")191 in := []byte("\x1b[<0;1;1M")
187- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {192+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
188 chunks = append(chunks, append([]byte(nil), b...))193 chunks = append(chunks, append([]byte(nil), b...))
189 })194 })
190 195
@@ -204,7 +209,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
204 var chunks [][]byte209 var chunks [][]byte
205 210
206 in := []byte("\x1b[A")211 in := []byte("\x1b[A")
207- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {212+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
208 chunks = append(chunks, append([]byte(nil), b...))213 chunks = append(chunks, append([]byte(nil), b...))
209 })214 })
210 215
@@ -229,7 +234,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
229 kind int234 kind int
230 rgb renderer.RGB235 rgb renderer.RGB
231 }{kind: kind, rgb: rgb})236 }{kind: kind, rgb: rgb})
232- }, func(light bool) {}, func(b []byte) {237+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
233 chunks = append(chunks, append([]byte(nil), b...))238 chunks = append(chunks, append([]byte(nil), b...))
234 })239 })
235 240
@@ -254,8 +259,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
254 var s Scanner259 var s Scanner
255 var out bytes.Buffer260 var out bytes.Buffer
256 261
257- s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })262+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
258- s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })263+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
259 264
260 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {265 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
261 t.Fatalf("overflow passthrough=%q", out.String())266 t.Fatalf("overflow passthrough=%q", out.String())

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close