feat(daemon): gate terminal palette through effective theme
- commit
- 75e21f896377904ec3d1685e37431a3002dc024f
- author
- brice · · 2026-07-17 08:26 UTC
- parent
- 0b10c36
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +112 -10
M internal/usecase/daemon/client.go +10 -8
| @@ -698,14 +698,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 698 | 698 | ||
| 699 | 699 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 700 | 700 | 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, | |
| 709 | 711 | } | |
| 710 | 712 | ac.setClientTheme(clientTheme) | |
| 711 | 713 | 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 +2 -0
| @@ -36,6 +36,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 36 | 36 | tabs := cfg.Tabs | |
| 37 | 37 | d.tabsConfig.Store(&tabs) | |
| 38 | 38 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 39 | + | d.themePaletteOff.Store(!cfg.ThemePalette) | |
| 39 | 40 | if d.barScripts != nil { | |
| 40 | 41 | d.barScripts.mu.Lock() | |
| 41 | 42 | d.barScripts.initLocked() | |
| @@ -246,6 +247,7 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |||
| 246 | 247 | case domain.ThemeLight: | |
| 247 | 248 | return theme.BuiltinLight | |
| 248 | 249 | default: | |
| 250 | + | clientTheme.UsePalette = !d.themePaletteOff.Load() | |
| 249 | 251 | return clientTheme | |
| 250 | 252 | } | |
| 251 | 253 | } | |
| @@ -36,6 +36,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 36 | tabs := cfg.Tabs | 36 | tabs := cfg.Tabs | ||
| 37 | d.tabsConfig.Store(&tabs) | 37 | d.tabsConfig.Store(&tabs) | ||
| 38 | d.themeMode.Store(uint32(cfg.Theme)) | 38 | d.themeMode.Store(uint32(cfg.Theme)) | ||
| 39 | + | d.themePaletteOff.Store(!cfg.ThemePalette) | |||
| 39 | if d.barScripts != nil { | 40 | if d.barScripts != nil { | ||
| 40 | d.barScripts.mu.Lock() | 41 | d.barScripts.mu.Lock() | ||
| 41 | d.barScripts.initLocked() | 42 | d.barScripts.initLocked() | ||
| @@ -246,6 +247,7 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |||||
| 246 | case domain.ThemeLight: | 247 | case domain.ThemeLight: | ||
| 247 | return theme.BuiltinLight | 248 | return theme.BuiltinLight | ||
| 248 | default: | 249 | default: | ||
| 250 | + | clientTheme.UsePalette = !d.themePaletteOff.Load() | |||
| 249 | return clientTheme | 251 | return clientTheme | ||
| 250 | } | 252 | } | ||
| 251 | } | 253 | } | ||
M internal/usecase/daemon/config_test.go +95 -0
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | 10 | "github.com/bnema/vev/internal/usecase/keys" | |
| 11 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | type captureKeyHandler struct { | |
| @@ -50,6 +52,99 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 50 | 52 | require.Equal(t, "NT", cmd.Code) | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 55 | + | func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) { | |
| 56 | + | p, release := newBlockingPTY(t) | |
| 57 | + | defer release() | |
| 58 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 59 | + | ||
| 60 | + | clientPalette := [16]renderer.RGB{} | |
| 61 | + | clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3} | |
| 62 | + | clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6} | |
| 63 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 64 | + | HasForeground: true, | |
| 65 | + | Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |
| 66 | + | HasBackground: true, | |
| 67 | + | Background: renderer.RGB{R: 10, G: 11, B: 12}, | |
| 68 | + | Palette: clientPalette, | |
| 69 | + | PaletteKnown: 1<<1 | 1<<12, | |
| 70 | + | }) | |
| 71 | + | ||
| 72 | + | require.Equal(t, clientPalette, ac.getClientTheme().Palette) | |
| 73 | + | require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown) | |
| 74 | + | require.True(t, ac.getTheme().UsePalette) | |
| 75 | + | ||
| 76 | + | cfg := domain.Defaults() | |
| 77 | + | cfg.ThemePalette = false | |
| 78 | + | d.ApplyConfig(cfg) | |
| 79 | + | ||
| 80 | + | // ApplyConfig reuses the live client report and repaints without requiring | |
| 81 | + | // a reconnect; only palette inheritance changes. | |
| 82 | + | reloaded := ac.getTheme() | |
| 83 | + | require.Equal(t, clientPalette, reloaded.Palette) | |
| 84 | + | require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown) | |
| 85 | + | require.False(t, reloaded.UsePalette) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestEffectiveThemePaletteGate(t *testing.T) { | |
| 89 | + | clientPalette := [16]renderer.RGB{} | |
| 90 | + | clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3} | |
| 91 | + | clientTheme := themeui.Theme{ | |
| 92 | + | Palette: clientPalette, | |
| 93 | + | PaletteKnown: 1 << 3, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | tests := []struct { | |
| 97 | + | name string | |
| 98 | + | config domain.Config | |
| 99 | + | want themeui.Theme | |
| 100 | + | usePalette bool | |
| 101 | + | }{ | |
| 102 | + | { | |
| 103 | + | name: "auto inherits terminal palette by default", | |
| 104 | + | config: domain.Defaults(), | |
| 105 | + | want: clientTheme, | |
| 106 | + | usePalette: true, | |
| 107 | + | }, | |
| 108 | + | { | |
| 109 | + | name: "auto disables terminal palette", | |
| 110 | + | config: func() domain.Config { | |
| 111 | + | cfg := domain.Defaults() | |
| 112 | + | cfg.ThemePalette = false | |
| 113 | + | return cfg | |
| 114 | + | }(), | |
| 115 | + | want: clientTheme, | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "forced dark uses palette free builtin", | |
| 119 | + | config: domain.Config{ | |
| 120 | + | Theme: domain.ThemeDark, | |
| 121 | + | ThemePalette: true, | |
| 122 | + | }, | |
| 123 | + | want: themeui.BuiltinDark, | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: "forced light uses palette free builtin", | |
| 127 | + | config: domain.Config{ | |
| 128 | + | Theme: domain.ThemeLight, | |
| 129 | + | ThemePalette: true, | |
| 130 | + | }, | |
| 131 | + | want: themeui.BuiltinLight, | |
| 132 | + | }, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 138 | + | d.ApplyConfig(tt.config) | |
| 139 | + | ||
| 140 | + | got := d.effectiveTheme(clientTheme) | |
| 141 | + | want := tt.want | |
| 142 | + | want.UsePalette = tt.usePalette | |
| 143 | + | require.Equal(t, want, got) | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 53 | 148 | func TestApplyConfigPublishesCopyConfig(t *testing.T) { | |
| 54 | 149 | tests := []struct { | |
| 55 | 150 | name string | |
| @@ -8,6 +8,8 @@ import ( | |||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/usecase/keys" | 10 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 11 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | type captureKeyHandler struct { | 15 | type captureKeyHandler struct { | ||
| @@ -50,6 +52,99 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||||
| 50 | require.Equal(t, "NT", cmd.Code) | 52 | require.Equal(t, "NT", cmd.Code) | ||
| 51 | } | 53 | } | ||
| 52 | 54 | ||||
| 55 | + | func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) { | |||
| 56 | + | p, release := newBlockingPTY(t) | |||
| 57 | + | defer release() | |||
| 58 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 59 | + | ||||
| 60 | + | clientPalette := [16]renderer.RGB{} | |||
| 61 | + | clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 62 | + | clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6} | |||
| 63 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 64 | + | HasForeground: true, | |||
| 65 | + | Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 66 | + | HasBackground: true, | |||
| 67 | + | Background: renderer.RGB{R: 10, G: 11, B: 12}, | |||
| 68 | + | Palette: clientPalette, | |||
| 69 | + | PaletteKnown: 1<<1 | 1<<12, | |||
| 70 | + | }) | |||
| 71 | + | ||||
| 72 | + | require.Equal(t, clientPalette, ac.getClientTheme().Palette) | |||
| 73 | + | require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown) | |||
| 74 | + | require.True(t, ac.getTheme().UsePalette) | |||
| 75 | + | ||||
| 76 | + | cfg := domain.Defaults() | |||
| 77 | + | cfg.ThemePalette = false | |||
| 78 | + | d.ApplyConfig(cfg) | |||
| 79 | + | ||||
| 80 | + | // ApplyConfig reuses the live client report and repaints without requiring | |||
| 81 | + | // a reconnect; only palette inheritance changes. | |||
| 82 | + | reloaded := ac.getTheme() | |||
| 83 | + | require.Equal(t, clientPalette, reloaded.Palette) | |||
| 84 | + | require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown) | |||
| 85 | + | require.False(t, reloaded.UsePalette) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestEffectiveThemePaletteGate(t *testing.T) { | |||
| 89 | + | clientPalette := [16]renderer.RGB{} | |||
| 90 | + | clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 91 | + | clientTheme := themeui.Theme{ | |||
| 92 | + | Palette: clientPalette, | |||
| 93 | + | PaletteKnown: 1 << 3, | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | tests := []struct { | |||
| 97 | + | name string | |||
| 98 | + | config domain.Config | |||
| 99 | + | want themeui.Theme | |||
| 100 | + | usePalette bool | |||
| 101 | + | }{ | |||
| 102 | + | { | |||
| 103 | + | name: "auto inherits terminal palette by default", | |||
| 104 | + | config: domain.Defaults(), | |||
| 105 | + | want: clientTheme, | |||
| 106 | + | usePalette: true, | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "auto disables terminal palette", | |||
| 110 | + | config: func() domain.Config { | |||
| 111 | + | cfg := domain.Defaults() | |||
| 112 | + | cfg.ThemePalette = false | |||
| 113 | + | return cfg | |||
| 114 | + | }(), | |||
| 115 | + | want: clientTheme, | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "forced dark uses palette free builtin", | |||
| 119 | + | config: domain.Config{ | |||
| 120 | + | Theme: domain.ThemeDark, | |||
| 121 | + | ThemePalette: true, | |||
| 122 | + | }, | |||
| 123 | + | want: themeui.BuiltinDark, | |||
| 124 | + | }, | |||
| 125 | + | { | |||
| 126 | + | name: "forced light uses palette free builtin", | |||
| 127 | + | config: domain.Config{ | |||
| 128 | + | Theme: domain.ThemeLight, | |||
| 129 | + | ThemePalette: true, | |||
| 130 | + | }, | |||
| 131 | + | want: themeui.BuiltinLight, | |||
| 132 | + | }, | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 138 | + | d.ApplyConfig(tt.config) | |||
| 139 | + | ||||
| 140 | + | got := d.effectiveTheme(clientTheme) | |||
| 141 | + | want := tt.want | |||
| 142 | + | want.UsePalette = tt.usePalette | |||
| 143 | + | require.Equal(t, want, got) | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 53 | func TestApplyConfigPublishesCopyConfig(t *testing.T) { | 148 | func TestApplyConfigPublishesCopyConfig(t *testing.T) { | ||
| 54 | tests := []struct { | 149 | tests := []struct { | ||
| 55 | name string | 150 | name string | ||
M internal/usecase/daemon/daemon.go +5 -2
| @@ -145,8 +145,11 @@ type Daemon struct { | |||
| 145 | 145 | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 146 | 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 147 | 147 | themeMode atomic.Uint32 | |
| 148 | - | barScripts *barScriptState | |
| 149 | - | resumeParkGrace time.Duration | |
| 148 | + | // themePaletteOff is deliberately inverted so its zero value preserves the | |
| 149 | + | // default-on terminal palette inheritance before configuration is loaded. | |
| 150 | + | themePaletteOff atomic.Bool | |
| 151 | + | barScripts *barScriptState | |
| 152 | + | resumeParkGrace time.Duration | |
| 150 | 153 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 151 | 154 | // (see clipboard.go); empty means use os.TempDir(). | |
| 152 | 155 | tempDir string | |
| @@ -145,8 +145,11 @@ type Daemon struct { | |||||
| 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.Uint32 | 147 | themeMode atomic.Uint32 | ||
| 148 | - | barScripts *barScriptState | 148 | + | // themePaletteOff is deliberately inverted so its zero value preserves the |
| 149 | - | resumeParkGrace time.Duration | 149 | + | // default-on terminal palette inheritance before configuration is loaded. |
| 150 | + | themePaletteOff atomic.Bool | |||
| 151 | + | barScripts *barScriptState | |||
| 152 | + | resumeParkGrace time.Duration | |||
| 150 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 153 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 151 | // (see clipboard.go); empty means use os.TempDir(). | 154 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 152 | tempDir string | 155 | tempDir string | ||