detached 810e9f1

refactor(theme): cache resolved chrome styles

commit
810e9f167016c4e8cc1e49025d15551b823eda27
author
brice · · 2026-07-18 08:33 UTC
parent
6e468ca
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +192 -45
M internal/usecase/daemon/client.go +33 -7
@@ -23,6 +23,15 @@ type transportSnapshot struct {
2323 incarnation uint64
2424 }
2525
26+// appliedTheme is the single immutable chrome snapshot consumed by rendering.
27+// Raw retains terminal defaults for panes; Resolved belongs exclusively to vev
28+// chrome. Generation changes whenever either terminal input or policy changes.
29+type appliedTheme struct {
30+ Raw themeui.Theme
31+ Resolved themeui.ResolvedTheme
32+ Generation uint64
33+}
34+
2635 type attachedClient struct {
2736 tr ports.Transport
2837 transportIncarnation uint64
@@ -48,8 +57,9 @@ type attachedClient struct {
4857 sess Guarded[*session]
4958 mouseScan mouse.Scanner
5059 themeMu sync.Mutex
51- theme themeui.Theme
60+ theme themeui.Theme // compatibility mirror of applied.Raw
5261 clientTheme themeui.Theme
62+ appliedTheme appliedTheme
5363 lastCursor cursorOut
5464 renderStages renderStageHooks // optional render and handoff observability hooks
5565 // previousSession is guarded independently. It is retained through temporary
@@ -138,9 +148,18 @@ func (ac *attachedClient) clearCaptureFrames() {
138148 }
139149
140150 func (ac *attachedClient) getTheme() themeui.Theme {
151+ return ac.getAppliedTheme().Raw
152+}
153+
154+func (ac *attachedClient) getAppliedTheme() appliedTheme {
141155 ac.themeMu.Lock()
142156 defer ac.themeMu.Unlock()
143- return ac.theme
157+ if ac.appliedTheme.Generation == 0 {
158+ // An unattached client has no terminal report yet. Reuse the static
159+ // neutral cache rather than resolving from a render path.
160+ return appliedTheme{Raw: ac.theme, Resolved: themeui.ResolvedTheme{Theme: ac.theme, Styles: fallbackChromeStyles}}
161+ }
162+ return ac.appliedTheme
144163 }
145164
146165 func (ac *attachedClient) getClientTheme() themeui.Theme {
@@ -150,8 +169,16 @@ func (ac *attachedClient) getClientTheme() themeui.Theme {
150169 }
151170
152171 func (ac *attachedClient) setTheme(t themeui.Theme) {
172+ // Test and legacy setup paths have no daemon policy context. Keep their
173+ // neutral automatic behavior while publishing a complete immutable value.
174+ ac.setAppliedTheme(appliedTheme{Raw: t, Resolved: themeui.Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
175+}
176+
177+func (ac *attachedClient) setAppliedTheme(next appliedTheme) {
153178 ac.themeMu.Lock()
154- ac.theme = t
179+ next.Generation = ac.appliedTheme.Generation + 1
180+ ac.appliedTheme = next
181+ ac.theme = next.Raw
155182 ac.themeMu.Unlock()
156183 }
157184
@@ -432,7 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
432459 sess.mu.Unlock()
433460 d.touchMRU(sess)
434461 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
435- d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
462+ d.applyHostTheme(sess, ac, themeui.Theme{}, true)
436463 return ac, old, cleanup
437464 }
438465
@@ -526,7 +553,7 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
526553 // (and run its own attach-time reset), so this call must leave the tabs
527554 // alone rather than clobbering that client's freshly applied colors.
528555 func (d *Daemon) resetScreenDefaultColors(sess *session) {
529- d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
556+ d.applyHostTheme(sess, nil, themeui.Theme{}, true)
530557 }
531558
532559 // retireReplacedClient owns old-link retirement and obsolete render-worker
@@ -710,9 +737,8 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
710737 Light: msg.Light,
711738 }
712739 ac.setClientTheme(clientTheme)
713- t := d.effectiveTheme(clientTheme)
714740
715- if !d.applyHostTheme(sess, ac, t, false) {
741+ if !d.applyHostTheme(sess, ac, clientTheme, false) {
716742 return
717743 }
718744 d.invalidateRender(sess, ac, true, "client.go")
@@ -23,6 +23,15 @@ type transportSnapshot struct {
23 incarnation uint6423 incarnation uint64
24 }24 }
25 25
26+// appliedTheme is the single immutable chrome snapshot consumed by rendering.
27+// Raw retains terminal defaults for panes; Resolved belongs exclusively to vev
28+// chrome. Generation changes whenever either terminal input or policy changes.
29+type appliedTheme struct {
30+ Raw themeui.Theme
31+ Resolved themeui.ResolvedTheme
32+ Generation uint64
33+}
34+
26 type attachedClient struct {35 type attachedClient struct {
27 tr ports.Transport36 tr ports.Transport
28 transportIncarnation uint6437 transportIncarnation uint64
@@ -48,8 +57,9 @@ type attachedClient struct {
48 sess Guarded[*session]57 sess Guarded[*session]
49 mouseScan mouse.Scanner58 mouseScan mouse.Scanner
50 themeMu sync.Mutex59 themeMu sync.Mutex
51- theme themeui.Theme60+ theme themeui.Theme // compatibility mirror of applied.Raw
52 clientTheme themeui.Theme61 clientTheme themeui.Theme
62+ appliedTheme appliedTheme
53 lastCursor cursorOut63 lastCursor cursorOut
54 renderStages renderStageHooks // optional render and handoff observability hooks64 renderStages renderStageHooks // optional render and handoff observability hooks
55 // previousSession is guarded independently. It is retained through temporary65 // previousSession is guarded independently. It is retained through temporary
@@ -138,9 +148,18 @@ func (ac *attachedClient) clearCaptureFrames() {
138 }148 }
139 149
140 func (ac *attachedClient) getTheme() themeui.Theme {150 func (ac *attachedClient) getTheme() themeui.Theme {
151+ return ac.getAppliedTheme().Raw
152+}
153+
154+func (ac *attachedClient) getAppliedTheme() appliedTheme {
141 ac.themeMu.Lock()155 ac.themeMu.Lock()
142 defer ac.themeMu.Unlock()156 defer ac.themeMu.Unlock()
143- return ac.theme157+ if ac.appliedTheme.Generation == 0 {
158+ // An unattached client has no terminal report yet. Reuse the static
159+ // neutral cache rather than resolving from a render path.
160+ return appliedTheme{Raw: ac.theme, Resolved: themeui.ResolvedTheme{Theme: ac.theme, Styles: fallbackChromeStyles}}
161+ }
162+ return ac.appliedTheme
144 }163 }
145 164
146 func (ac *attachedClient) getClientTheme() themeui.Theme {165 func (ac *attachedClient) getClientTheme() themeui.Theme {
@@ -150,8 +169,16 @@ func (ac *attachedClient) getClientTheme() themeui.Theme {
150 }169 }
151 170
152 func (ac *attachedClient) setTheme(t themeui.Theme) {171 func (ac *attachedClient) setTheme(t themeui.Theme) {
172+ // Test and legacy setup paths have no daemon policy context. Keep their
173+ // neutral automatic behavior while publishing a complete immutable value.
174+ ac.setAppliedTheme(appliedTheme{Raw: t, Resolved: themeui.Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
175+}
176+
177+func (ac *attachedClient) setAppliedTheme(next appliedTheme) {
153 ac.themeMu.Lock()178 ac.themeMu.Lock()
154- ac.theme = t179+ next.Generation = ac.appliedTheme.Generation + 1
180+ ac.appliedTheme = next
181+ ac.theme = next.Raw
155 ac.themeMu.Unlock()182 ac.themeMu.Unlock()
156 }183 }
157 184
@@ -432,7 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
432 sess.mu.Unlock()459 sess.mu.Unlock()
433 d.touchMRU(sess)460 d.touchMRU(sess)
434 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)461 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
435- d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)462+ d.applyHostTheme(sess, ac, themeui.Theme{}, true)
436 return ac, old, cleanup463 return ac, old, cleanup
437 }464 }
438 465
@@ -526,7 +553,7 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
526 // (and run its own attach-time reset), so this call must leave the tabs553 // (and run its own attach-time reset), so this call must leave the tabs
527 // alone rather than clobbering that client's freshly applied colors.554 // alone rather than clobbering that client's freshly applied colors.
528 func (d *Daemon) resetScreenDefaultColors(sess *session) {555 func (d *Daemon) resetScreenDefaultColors(sess *session) {
529- d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)556+ d.applyHostTheme(sess, nil, themeui.Theme{}, true)
530 }557 }
531 558
532 // retireReplacedClient owns old-link retirement and obsolete render-worker559 // retireReplacedClient owns old-link retirement and obsolete render-worker
@@ -710,9 +737,8 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
710 Light: msg.Light,737 Light: msg.Light,
711 }738 }
712 ac.setClientTheme(clientTheme)739 ac.setClientTheme(clientTheme)
713- t := d.effectiveTheme(clientTheme)
714 740
715- if !d.applyHostTheme(sess, ac, t, false) {741+ if !d.applyHostTheme(sess, ac, clientTheme, false) {
716 return742 return
717 }743 }
718 d.invalidateRender(sess, ac, true, "client.go")744 d.invalidateRender(sess, ac, true, "client.go")
M internal/usecase/daemon/config.go +15 -5
@@ -21,10 +21,11 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
2121 type themeConfigSnapshot struct {
2222 mode domain.ThemeMode
2323 paletteOff bool
24+ accent domain.ThemeAccent
2425 }
2526
2627 func (d *Daemon) storeThemeConfig(cfg domain.Config) {
27- snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette}
28+ snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
2829 d.themeConfig.Store(&snapshot)
2930 }
3031
@@ -259,8 +260,7 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
259260 return command.Command{}, false
260261 }
261262
262-func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
263- config := d.currentThemeConfig()
263+func effectiveThemeForConfig(clientTheme theme.Theme, config themeConfigSnapshot) theme.Theme {
264264 switch config.mode {
265265 case domain.ThemeDark:
266266 return theme.BuiltinDark
@@ -272,6 +272,16 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
272272 }
273273 }
274274
275+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
276+ return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
277+}
278+
279+func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
280+ config := d.currentThemeConfig()
281+ effective := effectiveThemeForConfig(raw, config)
282+ return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}
283+}
284+
275285 func (d *Daemon) reapplyThemeAllSessions() {
276286 d.mu.Lock()
277287 sessions := make([]*session, 0, len(d.sessions))
@@ -290,12 +300,12 @@ func (d *Daemon) reapplyThemeSession(sess *session) {
290300 ac := sess.client
291301 sess.mu.Unlock()
292302 if ac == nil {
293- d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true)
303+ d.applyHostTheme(sess, nil, theme.Theme{}, true)
294304 return
295305 }
296306 ac.sendMu.Lock()
297307 defer ac.sendMu.Unlock()
298308 sess.themeMu.Lock()
299309 defer sess.themeMu.Unlock()
300- d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false)
310+ d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
301311 }
@@ -21,10 +21,11 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
21 type themeConfigSnapshot struct {21 type themeConfigSnapshot struct {
22 mode domain.ThemeMode22 mode domain.ThemeMode
23 paletteOff bool23 paletteOff bool
24+ accent domain.ThemeAccent
24 }25 }
25 26
26 func (d *Daemon) storeThemeConfig(cfg domain.Config) {27 func (d *Daemon) storeThemeConfig(cfg domain.Config) {
27- snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette}28+ snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
28 d.themeConfig.Store(&snapshot)29 d.themeConfig.Store(&snapshot)
29 }30 }
30 31
@@ -259,8 +260,7 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
259 return command.Command{}, false260 return command.Command{}, false
260 }261 }
261 262
262-func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {263+func effectiveThemeForConfig(clientTheme theme.Theme, config themeConfigSnapshot) theme.Theme {
263- config := d.currentThemeConfig()
264 switch config.mode {264 switch config.mode {
265 case domain.ThemeDark:265 case domain.ThemeDark:
266 return theme.BuiltinDark266 return theme.BuiltinDark
@@ -272,6 +272,16 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
272 }272 }
273 }273 }
274 274
275+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
276+ return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
277+}
278+
279+func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
280+ config := d.currentThemeConfig()
281+ effective := effectiveThemeForConfig(raw, config)
282+ return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}
283+}
284+
275 func (d *Daemon) reapplyThemeAllSessions() {285 func (d *Daemon) reapplyThemeAllSessions() {
276 d.mu.Lock()286 d.mu.Lock()
277 sessions := make([]*session, 0, len(d.sessions))287 sessions := make([]*session, 0, len(d.sessions))
@@ -290,12 +300,12 @@ func (d *Daemon) reapplyThemeSession(sess *session) {
290 ac := sess.client300 ac := sess.client
291 sess.mu.Unlock()301 sess.mu.Unlock()
292 if ac == nil {302 if ac == nil {
293- d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true)303+ d.applyHostTheme(sess, nil, theme.Theme{}, true)
294 return304 return
295 }305 }
296 ac.sendMu.Lock()306 ac.sendMu.Lock()
297 defer ac.sendMu.Unlock()307 defer ac.sendMu.Unlock()
298 sess.themeMu.Lock()308 sess.themeMu.Lock()
299 defer sess.themeMu.Unlock()309 defer sess.themeMu.Unlock()
300- d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false)310+ d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
301 }311 }
M internal/usecase/daemon/copymode.go +1 -2
@@ -412,11 +412,10 @@ func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
412412 func isCopyEscapePrefix(data []byte) bool {
413413 return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
414414 }
415-func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
415+func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, styles themeui.Styles) (renderer.Frame, []renderer.Damage) {
416416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
417417 return frame, nil
418418 }
419- styles := themeui.NewStyles(bars.theme)
420419 copyFrame := mode.Render(styles.CopyStatus, styles.Selection)
421420 bodyRows := max(copyFrame.Height-1, 0)
422421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
@@ -412,11 +412,10 @@ func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
412 func isCopyEscapePrefix(data []byte) bool {412 func isCopyEscapePrefix(data []byte) bool {
413 return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')413 return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
414 }414 }
415-func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {415+func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, styles themeui.Styles) (renderer.Frame, []renderer.Damage) {
416 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 {
417 return frame, nil417 return frame, nil
418 }418 }
419- styles := themeui.NewStyles(bars.theme)
420 copyFrame := mode.Render(styles.CopyStatus, styles.Selection)419 copyFrame := mode.Render(styles.CopyStatus, styles.Selection)
421 bodyRows := max(copyFrame.Height-1, 0)420 bodyRows := max(copyFrame.Height-1, 0)
422 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
M internal/usecase/daemon/copymode_test.go +2 -3
@@ -115,7 +115,6 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
115115 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118- bars := barState{status: sess.statusSegments(true)}
119118
120119 base := renderer.NewFrame(80, 25)
121120 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
@@ -133,7 +132,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133132 go func() {
134133 defer wg.Done()
135134 for range 200 {
136- _, _ = composeCopyClientFrame(mode, target, base, bars)
135+ _, _ = composeCopyClientFrame(mode, target, base, resolveStyles(nil))
137136 }
138137 }()
139138 wg.Wait()
@@ -160,7 +159,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
160159 }},
161160 bars: bars,
162161 }, composeCacheInput{}).frame
163- frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
162+ frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, resolveStyles(nil))
164163
165164 require.Equal(t, 80, frame.Width)
166165 require.Equal(t, 25, frame.Height)
@@ -115,7 +115,6 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
115 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)115 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116 pane.mu.Unlock()116 pane.mu.Unlock()
117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118- bars := barState{status: sess.statusSegments(true)}
119 118
120 base := renderer.NewFrame(80, 25)119 base := renderer.NewFrame(80, 25)
121 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}120 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
@@ -133,7 +132,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133 go func() {132 go func() {
134 defer wg.Done()133 defer wg.Done()
135 for range 200 {134 for range 200 {
136- _, _ = composeCopyClientFrame(mode, target, base, bars)135+ _, _ = composeCopyClientFrame(mode, target, base, resolveStyles(nil))
137 }136 }
138 }()137 }()
139 wg.Wait()138 wg.Wait()
@@ -160,7 +159,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
160 }},159 }},
161 bars: bars,160 bars: bars,
162 }, composeCacheInput{}).frame161 }, composeCacheInput{}).frame
163- frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)162+ frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, resolveStyles(nil))
164 163
165 require.Equal(t, 80, frame.Width)164 require.Equal(t, 80, frame.Width)
166 require.Equal(t, 25, frame.Height)165 require.Equal(t, 25, frame.Height)
M internal/usecase/daemon/floating_render.go +3 -1
@@ -92,6 +92,7 @@ type floatingComposeInput struct {
9292 content domain.Rect
9393 layout capturedTabLayout
9494 theme themeui.Theme
95+ styles themeui.Styles
9596 cache composeCacheInput
9697 full bool
9798 }
@@ -103,6 +104,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
103104 content := input.content
104105 layoutSnap := input.layout
105106 theme := input.theme
107+ styles := input.styles
106108 cache := input.cache
107109 full := input.full
108110
@@ -117,7 +119,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
117119 }
118120 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
119121 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
120- drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border)
122+ drawFloatingBorder(frame, geometry.Bounds, floating.title, styles.BorderMuted)
121123 if full || popupChanged {
122124 return frame, []renderer.Damage{renderer.FullRedraw()}
123125 }
@@ -92,6 +92,7 @@ type floatingComposeInput struct {
92 content domain.Rect92 content domain.Rect
93 layout capturedTabLayout93 layout capturedTabLayout
94 theme themeui.Theme94 theme themeui.Theme
95+ styles themeui.Styles
95 cache composeCacheInput96 cache composeCacheInput
96 full bool97 full bool
97 }98 }
@@ -103,6 +104,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
103 content := input.content104 content := input.content
104 layoutSnap := input.layout105 layoutSnap := input.layout
105 theme := input.theme106 theme := input.theme
107+ styles := input.styles
106 cache := input.cache108 cache := input.cache
107 full := input.full109 full := input.full
108 110
@@ -117,7 +119,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
117 }119 }
118 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry120 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
119 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration121 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
120- drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border)122+ drawFloatingBorder(frame, geometry.Bounds, floating.title, styles.BorderMuted)
121 if full || popupChanged {123 if full || popupChanged {
122 return frame, []renderer.Damage{renderer.FullRedraw()}124 return frame, []renderer.Damage{renderer.FullRedraw()}
123 }125 }
M internal/usecase/daemon/picker_test.go +3 -2
@@ -653,6 +653,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T
653653 t.Run(tt.name, func(t *testing.T) {
654654 state := capturedRenderState{
655655 theme: tt.theme,
656+ styles: themeui.Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
656657 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
657658 }
658659 snap := &overlayRenderSnapshot{
@@ -665,7 +666,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T
665666 inner := state.overlays.picker.inner
666667 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
667668 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
668- want := themeui.MutedTextStyle(tt.theme)
669+ want := state.styles.PickerSeparator
669670 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
670671 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
671672 cell := inner.At(x, y)
@@ -679,7 +680,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T
679680
680681 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
681682 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
682- state := capturedRenderState{theme: themeui.BuiltinDark}
683+ state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
683684 cases := []struct {
684685 name string
685686 size domain.Size
@@ -653,6 +653,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T
653 t.Run(tt.name, func(t *testing.T) {653 t.Run(tt.name, func(t *testing.T) {
654 state := capturedRenderState{654 state := capturedRenderState{
655 theme: tt.theme,655 theme: tt.theme,
656+ styles: themeui.Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
656 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},657 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
657 }658 }
658 snap := &overlayRenderSnapshot{659 snap := &overlayRenderSnapshot{
@@ -665,7 +666,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T
665 inner := state.overlays.picker.inner666 inner := state.overlays.picker.inner
666 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})667 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
667 require.Equal(t, picker.LayoutHorizontal, layout.Mode)668 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
668- want := themeui.MutedTextStyle(tt.theme)669+ want := state.styles.PickerSeparator
669 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {670 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
670 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {671 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
671 cell := inner.At(x, y)672 cell := inner.At(x, y)
@@ -679,7 +680,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T
679 680
680 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {681 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
681 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)682 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
682- state := capturedRenderState{theme: themeui.BuiltinDark}683+ state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
683 cases := []struct {684 cases := []struct {
684 name string685 name string
685 size domain.Size686 size domain.Size
M internal/usecase/daemon/render.go +13 -8
@@ -295,7 +295,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
295295 // Contextual ranks are completely captured under paletteMu. Compose them
296296 // without reading the live MRU, whose order may have changed mid-interaction.
297297 bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
298- bars.theme = ac.getTheme()
298+ applied := ac.getAppliedTheme()
299+ bars.theme = applied.Raw
299300 attentionVisible := pulseVisible(bars.attentionFrame)
300301 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
301302
@@ -326,12 +327,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
326327 }
327328 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
328329 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
329- bars: bars,
330- overlays: capturedOverlays,
331- preview: preview,
332- floatingCfg: floatingCfg,
333- reset: reset,
334- lease: lease,
330+ bars: bars,
331+ overlays: capturedOverlays,
332+ preview: preview,
333+ floatingCfg: floatingCfg,
334+ styles: applied.Resolved.Styles,
335+ styleGeneration: applied.Generation,
336+ reset: reset,
337+ lease: lease,
335338 })
336339 endCapture(0, ok)
337340 if !ok {
@@ -357,11 +360,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
357360 d.emitFrame(sess, ac, state, composed, &marks)
358361 }
359362
363+var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
364+
360365 func resolveStyles(styles []themeui.Styles) themeui.Styles {
361366 if len(styles) > 0 {
362367 return styles[0]
363368 }
364- return themeui.NewStyles(themeui.Theme{})
369+ return fallbackChromeStyles
365370 }
366371
367372 type barCache struct {
@@ -295,7 +295,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
295 // Contextual ranks are completely captured under paletteMu. Compose them295 // Contextual ranks are completely captured under paletteMu. Compose them
296 // without reading the live MRU, whose order may have changed mid-interaction.296 // without reading the live MRU, whose order may have changed mid-interaction.
297 bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)297 bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
298- bars.theme = ac.getTheme()298+ applied := ac.getAppliedTheme()
299+ bars.theme = applied.Raw
299 attentionVisible := pulseVisible(bars.attentionFrame)300 attentionVisible := pulseVisible(bars.attentionFrame)
300 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)301 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
301 302
@@ -326,12 +327,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
326 }327 }
327 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)328 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
328 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{329 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
329- bars: bars,330+ bars: bars,
330- overlays: capturedOverlays,331+ overlays: capturedOverlays,
331- preview: preview,332+ preview: preview,
332- floatingCfg: floatingCfg,333+ floatingCfg: floatingCfg,
333- reset: reset,334+ styles: applied.Resolved.Styles,
334- lease: lease,335+ styleGeneration: applied.Generation,
336+ reset: reset,
337+ lease: lease,
335 })338 })
336 endCapture(0, ok)339 endCapture(0, ok)
337 if !ok {340 if !ok {
@@ -357,11 +360,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
357 d.emitFrame(sess, ac, state, composed, &marks)360 d.emitFrame(sess, ac, state, composed, &marks)
358 }361 }
359 362
363+var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
364+
360 func resolveStyles(styles []themeui.Styles) themeui.Styles {365 func resolveStyles(styles []themeui.Styles) themeui.Styles {
361 if len(styles) > 0 {366 if len(styles) > 0 {
362 return styles[0]367 return styles[0]
363 }368 }
364- return themeui.NewStyles(themeui.Theme{})369+ return fallbackChromeStyles
365 }370 }
366 371
367 type barCache struct {372 type barCache struct {
M internal/usecase/daemon/render_capture.go +11 -6
@@ -43,6 +43,8 @@ type capturedRenderState struct {
4343 floating capturedFloatingRenderState
4444 bars barState
4545 theme themeui.Theme
46+ styles themeui.Styles
47+ styleGeneration uint64
4648 overlays capturedOverlayRenderState
4749 preview picker.Preview
4850 cursor capturedCursorInputs
@@ -166,12 +168,14 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
166168 }
167169
168170 type primaryCaptureRequest struct {
169- bars barState
170- overlays capturedOverlayRenderState
171- preview picker.Preview
172- floatingCfg domain.FloatingConfig
173- reset bool
174- lease *attachmentLease
171+ bars barState
172+ overlays capturedOverlayRenderState
173+ preview picker.Preview
174+ floatingCfg domain.FloatingConfig
175+ styles themeui.Styles
176+ styleGeneration uint64
177+ reset bool
178+ lease *attachmentLease
175179 }
176180
177181 // capturePrimaryRenderState is the ownership boundary for a primary render
@@ -227,6 +231,7 @@ func capturePrimaryRenderState(
227231 state := &scratch.state
228232 *state = capturedRenderState{
229233 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
234+ styles: request.styles, styleGeneration: request.styleGeneration,
230235 overlays: overlays, preview: preview,
231236 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
232237 floatingGeneration: tb.floating.generation,
@@ -43,6 +43,8 @@ type capturedRenderState struct {
43 floating capturedFloatingRenderState43 floating capturedFloatingRenderState
44 bars barState44 bars barState
45 theme themeui.Theme45 theme themeui.Theme
46+ styles themeui.Styles
47+ styleGeneration uint64
46 overlays capturedOverlayRenderState48 overlays capturedOverlayRenderState
47 preview picker.Preview49 preview picker.Preview
48 cursor capturedCursorInputs50 cursor capturedCursorInputs
@@ -166,12 +168,14 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
166 }168 }
167 169
168 type primaryCaptureRequest struct {170 type primaryCaptureRequest struct {
169- bars barState171+ bars barState
170- overlays capturedOverlayRenderState172+ overlays capturedOverlayRenderState
171- preview picker.Preview173+ preview picker.Preview
172- floatingCfg domain.FloatingConfig174+ floatingCfg domain.FloatingConfig
173- reset bool175+ styles themeui.Styles
174- lease *attachmentLease176+ styleGeneration uint64
177+ reset bool
178+ lease *attachmentLease
175 }179 }
176 180
177 // capturePrimaryRenderState is the ownership boundary for a primary render181 // capturePrimaryRenderState is the ownership boundary for a primary render
@@ -227,6 +231,7 @@ func capturePrimaryRenderState(
227 state := &scratch.state231 state := &scratch.state
228 *state = capturedRenderState{232 *state = capturedRenderState{
229 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,233 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
234+ styles: request.styles, styleGeneration: request.styleGeneration,
230 overlays: overlays, preview: preview,235 overlays: overlays, preview: preview,
231 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},236 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
232 floatingGeneration: tb.floating.generation,237 floatingGeneration: tb.floating.generation,
M internal/usecase/daemon/render_pipeline.go +17 -6
@@ -41,6 +41,7 @@ type composeCacheInput struct {
4141 floatingTitleGeneration uint64
4242 bars barCache
4343 theme themeui.Theme
44+ styleGeneration uint64
4445 }
4546
4647 type composedRenderFrame struct {
@@ -77,7 +78,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
7778 copy(frame.Row(y), in.frame.Row(y))
7879 }
7980 }
80- styles := themeui.NewStyles(state.theme)
81+ styles := state.styles
82+ if styles == (themeui.Styles{}) {
83+ // Direct/test-only composition has no applied snapshot; production
84+ // capture always supplies immutable styles.
85+ styles = fallbackChromeStyles
86+ }
87+ state.styles = styles
8188 defaultDimmer := themeui.NewDimmer(state.theme)
8289 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
8390 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
@@ -87,7 +94,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
8794 drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))
8895 }
8996
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.visible
97+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
9198 titles := scratch.titleGenerations
9299 if titles == nil {
93100 titles = make(map[layout.PaneID]uint64, len(state.panes))
@@ -125,6 +132,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
125132 content: content,
126133 layout: state.layout,
127134 theme: state.theme,
135+ styles: styles,
128136 cache: in,
129137 full: full || state.overlays.active(),
130138 })
@@ -152,7 +160,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
152160 cursorInputs := state.cursor
153161 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
154162 cursor := desiredCapturedCursor(cursorInputs)
155- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
163+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
156164 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
157165 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
158166 }
@@ -191,7 +199,10 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
191199 if snap.copyPane != nil {
192200 o.copyPaneID = snap.copyPane.id
193201 }
194- styles := themeui.NewStyles(state.theme)
202+ styles := state.styles
203+ if styles == (themeui.Styles{}) {
204+ styles = fallbackChromeStyles
205+ }
195206 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
196207 if snap.copySearchModel != nil {
197208 o.copySearch.modal = copySearchModal
@@ -238,7 +249,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
238249 }
239250 }
240251 }
241- frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars)
252+ frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles)
242253 }
243254 layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
244255 if o.paletteActive && !state.floating.visible {
@@ -248,7 +259,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
248259 if modal.inner.Width == 0 && modal.inner.Height == 0 {
249260 continue
250261 }
251- inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border)
262+ inner := modal.modal.Composite(frame, state.styles.BorderMuted)
252263 for y := range min(inner.Height, modal.inner.Height) {
253264 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)])
254265 }
@@ -41,6 +41,7 @@ type composeCacheInput struct {
41 floatingTitleGeneration uint6441 floatingTitleGeneration uint64
42 bars barCache42 bars barCache
43 theme themeui.Theme43 theme themeui.Theme
44+ styleGeneration uint64
44 }45 }
45 46
46 type composedRenderFrame struct {47 type composedRenderFrame struct {
@@ -77,7 +78,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
77 copy(frame.Row(y), in.frame.Row(y))78 copy(frame.Row(y), in.frame.Row(y))
78 }79 }
79 }80 }
80- styles := themeui.NewStyles(state.theme)81+ styles := state.styles
82+ if styles == (themeui.Styles{}) {
83+ // Direct/test-only composition has no applied snapshot; production
84+ // capture always supplies immutable styles.
85+ styles = fallbackChromeStyles
86+ }
87+ state.styles = styles
81 defaultDimmer := themeui.NewDimmer(state.theme)88 defaultDimmer := themeui.NewDimmer(state.theme)
82 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))89 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
83 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)90 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
@@ -87,7 +94,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
87 drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))94 drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))
88 }95 }
89 96
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.visible97+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
91 titles := scratch.titleGenerations98 titles := scratch.titleGenerations
92 if titles == nil {99 if titles == nil {
93 titles = make(map[layout.PaneID]uint64, len(state.panes))100 titles = make(map[layout.PaneID]uint64, len(state.panes))
@@ -125,6 +132,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
125 content: content,132 content: content,
126 layout: state.layout,133 layout: state.layout,
127 theme: state.theme,134 theme: state.theme,
135+ styles: styles,
128 cache: in,136 cache: in,
129 full: full || state.overlays.active(),137 full: full || state.overlays.active(),
130 })138 })
@@ -152,7 +160,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
152 cursorInputs := state.cursor160 cursorInputs := state.cursor
153 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()161 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
154 cursor := desiredCapturedCursor(cursorInputs)162 cursor := desiredCapturedCursor(cursorInputs)
155- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}163+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
156 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))164 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
157 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}165 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
158 }166 }
@@ -191,7 +199,10 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
191 if snap.copyPane != nil {199 if snap.copyPane != nil {
192 o.copyPaneID = snap.copyPane.id200 o.copyPaneID = snap.copyPane.id
193 }201 }
194- styles := themeui.NewStyles(state.theme)202+ styles := state.styles
203+ if styles == (themeui.Styles{}) {
204+ styles = fallbackChromeStyles
205+ }
195 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}206 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
196 if snap.copySearchModel != nil {207 if snap.copySearchModel != nil {
197 o.copySearch.modal = copySearchModal208 o.copySearch.modal = copySearchModal
@@ -238,7 +249,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
238 }249 }
239 }250 }
240 }251 }
241- frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars)252+ frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles)
242 }253 }
243 layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}254 layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
244 if o.paletteActive && !state.floating.visible {255 if o.paletteActive && !state.floating.visible {
@@ -248,7 +259,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
248 if modal.inner.Width == 0 && modal.inner.Height == 0 {259 if modal.inner.Width == 0 && modal.inner.Height == 0 {
249 continue260 continue
250 }261 }
251- inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border)262+ inner := modal.modal.Composite(frame, state.styles.BorderMuted)
252 for y := range min(inner.Height, modal.inner.Height) {263 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)])264 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 }265 }
M internal/usecase/daemon/render_pipeline_cache_test.go +5 -1
@@ -135,6 +135,7 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
135135 Foreground: renderer.RGB{R: 220, G: 210, B: 200},
136136 Background: renderer.RGB{R: 20, G: 30, B: 40},
137137 }
138+ state.styles = themeui.Resolve(state.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
138139 before := cloneCapturedStackState(state)
139140
140141 out := composeFrame(state, composeCacheInput{})
@@ -208,6 +209,7 @@ func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.
208209 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},
209210 panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
210211 theme: theme,
212+ styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
211213 }
212214 }
213215
@@ -221,6 +223,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
221223 reset: reset,
222224 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
223225 panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
226+ styles: resolveStyles(nil),
224227 }
225228 }
226229
@@ -241,7 +244,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
241244 func cacheBarState(bottom string, reset bool) capturedRenderState {
242245 pane := cachePaneFrame(6, 1, 'P')
243246 placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
244- return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}}
247+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}, styles: resolveStyles(nil)}
245248 }
246249
247250 func cachePaneFrame(width, height int, r rune) renderer.Frame {
@@ -263,6 +266,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
263266 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
264267 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
265268 bars: barState{topRight: title, bottomRight: title},
269+ styles: resolveStyles(nil),
266270 }
267271 }
268272
@@ -135,6 +135,7 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
135 Foreground: renderer.RGB{R: 220, G: 210, B: 200},135 Foreground: renderer.RGB{R: 220, G: 210, B: 200},
136 Background: renderer.RGB{R: 20, G: 30, B: 40},136 Background: renderer.RGB{R: 20, G: 30, B: 40},
137 }137 }
138+ state.styles = themeui.Resolve(state.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
138 before := cloneCapturedStackState(state)139 before := cloneCapturedStackState(state)
139 140
140 out := composeFrame(state, composeCacheInput{})141 out := composeFrame(state, composeCacheInput{})
@@ -208,6 +209,7 @@ func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.
208 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},209 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},
209 panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},210 panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
210 theme: theme,211 theme: theme,
212+ styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
211 }213 }
212 }214 }
213 215
@@ -221,6 +223,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
221 reset: reset,223 reset: reset,
222 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},224 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
223 panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},225 panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
226+ styles: resolveStyles(nil),
224 }227 }
225 }228 }
226 229
@@ -241,7 +244,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
241 func cacheBarState(bottom string, reset bool) capturedRenderState {244 func cacheBarState(bottom string, reset bool) capturedRenderState {
242 pane := cachePaneFrame(6, 1, 'P')245 pane := cachePaneFrame(6, 1, 'P')
243 placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}246 placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
244- return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}}247+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}, styles: resolveStyles(nil)}
245 }248 }
246 249
247 func cachePaneFrame(width, height int, r rune) renderer.Frame {250 func cachePaneFrame(width, height int, r rune) renderer.Frame {
@@ -263,6 +266,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
263 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},266 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
264 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},267 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
265 bars: barState{topRight: title, bottomRight: title},268 bars: barState{topRight: title, bottomRight: title},
269+ styles: resolveStyles(nil),
266 }270 }
267 }271 }
268 272
M internal/usecase/daemon/render_pipeline_test.go +1 -0
@@ -223,6 +223,7 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
223223 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
224224 overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
225225 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
226+ styles: resolveStyles(nil),
226227 }
227228 composed := composeFrame(state, composeCacheInput{})
228229 require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
@@ -223,6 +223,7 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
223 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},223 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
224 overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},224 overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
225 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},225 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
226+ styles: resolveStyles(nil),
226 }227 }
227 composed := composeFrame(state, composeCacheInput{})228 composed := composeFrame(state, composeCacheInput{})
228 require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))229 require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
M internal/usecase/daemon/render_test.go +1 -1
@@ -1178,7 +1178,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
11781178 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
11791179 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
11801180
1181- frame, damage := composeCopyClientFrame(mode, target, base, barState{})
1181+ frame, damage := composeCopyClientFrame(mode, target, base, resolveStyles(nil))
11821182
11831183 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
11841184 require.Equal(t, "##ab ", rowText(frame.Row(3)))
@@ -1178,7 +1178,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1178 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))1178 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
1179 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1179 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1180 1180
1181- frame, damage := composeCopyClientFrame(mode, target, base, barState{})1181+ frame, damage := composeCopyClientFrame(mode, target, base, resolveStyles(nil))
1182 1182
1183 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)1183 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1184 require.Equal(t, "##ab ", rowText(frame.Row(3)))1184 require.Equal(t, "##ab ", rowText(frame.Row(3)))
M internal/usecase/daemon/status_test.go +3 -2
@@ -417,9 +417,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
417417 Known: true,
418418 })
419419
420- bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
420+ applied := ac.getAppliedTheme()
421+ bars := barState{status: sess.statusSegments(true), theme: applied.Raw}
421422 composed := composeFrame(capturedRenderState{
422- reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme,
423+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme, styles: applied.Resolved.Styles, styleGeneration: applied.Generation,
423424 }, composeCacheInput{})
424425 out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)
425426
@@ -417,9 +417,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
417 Known: true,417 Known: true,
418 })418 })
419 419
420- bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}420+ applied := ac.getAppliedTheme()
421+ bars := barState{status: sess.statusSegments(true), theme: applied.Raw}
421 composed := composeFrame(capturedRenderState{422 composed := composeFrame(capturedRenderState{
422- reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme,423+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme, styles: applied.Resolved.Styles, styleGeneration: applied.Generation,
423 }, composeCacheInput{})424 }, composeCacheInput{})
424 out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)425 out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)
425 426
A internal/usecase/daemon/theme_cache_test.go +78 -0
@@ -0,0 +1,78 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ themeui "github.com/bnema/vev/internal/usecase/theme"
12+ "github.com/bnema/vev/pkg/renderer"
13+)
14+
15+func cacheTheme() themeui.Theme {
16+ palette := [16]renderer.RGB{}
17+ palette[2] = renderer.RGB{R: 30, G: 190, B: 150}
18+ palette[10] = palette[2]
19+ return themeui.Theme{
20+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
21+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
22+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
23+ }
24+}
25+
26+func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) {
27+ d, sess, ac, _ := newManualSessionWithPTYs(t)
28+ raw := cacheTheme()
29+ d.applyTheme(sess, ac, ports.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})
30+ before := ac.getAppliedTheme()
31+
32+ cfg := domain.Defaults()
33+ cfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}
34+ d.ApplyConfig(cfg)
35+ after := ac.getAppliedTheme()
36+
37+ require.Equal(t, before.Raw, after.Raw, "an accent-only reload retains the definitive client palette")
38+ require.Greater(t, after.Generation, before.Generation)
39+ require.Equal(t, themeui.Resolve(after.Raw, cfg.ThemeAccent), after.Resolved)
40+ require.Equal(t, uint8(2), after.Resolved.Accent.Slot)
41+}
42+
43+func TestComposeCacheInvalidatesOnStyleGenerationWithoutPaneRecoloring(t *testing.T) {
44+ raw := cacheTheme()
45+ first := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
46+ second := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
47+ pane := renderer.NewFrame(4, 1)
48+ pane.Set(0, 0, renderer.Cell{Rune: 'P', Style: renderer.Style{Foreground: 196}})
49+ state := capturedRenderState{
50+ reset: false, theme: raw, styles: first.Styles, styleGeneration: 1,
51+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "p", valid: true, fingerprint: "same", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}}},
52+ panes: []capturedPaneRenderState{{id: "p", frame: pane, focused: true, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, damage: []renderer.Damage{renderer.FullRedraw()}}},
53+ }
54+ committed := composeFrame(state, composeCacheInput{})
55+ state.reset, state.styles, state.styleGeneration, state.panes[0].damage = false, second.Styles, 2, nil
56+ out := composeFrame(state, committed.cache, composeCacheInput{})
57+
58+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
59+ require.Equal(t, pane.At(0, 0), out.frame.At(0, 1), "captured application cells must not receive chrome styles")
60+}
61+
62+func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
63+ raw := cacheTheme()
64+ resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
65+ cell := renderer.Cell{Rune: 'X', Style: renderer.Style{Foreground: 196}}
66+ focused, inactive := renderer.NewFrame(2, 1), renderer.NewFrame(2, 1)
67+ focused.Set(0, 0, cell)
68+ inactive.Set(0, 0, cell)
69+ placements := []layout.Placement{{ID: "focused", Content: domain.Rect{Width: 2, Height: 1}}, {ID: "inactive", Content: domain.Rect{X: 2, Width: 2, Height: 1}}}
70+ state := capturedRenderState{
71+ reset: true, theme: raw, styles: resolved.Styles, styleGeneration: 1,
72+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "focused", valid: true, placements: placements},
73+ panes: []capturedPaneRenderState{{id: "focused", frame: focused, focused: true, placement: placements[0], damage: []renderer.Damage{renderer.FullRedraw()}}, {id: "inactive", frame: inactive, placement: placements[1], damage: []renderer.Damage{renderer.FullRedraw()}}},
74+ }
75+ out := composeFrame(state, composeCacheInput{})
76+ require.Equal(t, cell, out.frame.At(0, 1))
77+ require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)
78+}
@@ -0,0 +1,78 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ themeui "github.com/bnema/vev/internal/usecase/theme"
12+ "github.com/bnema/vev/pkg/renderer"
13+)
14+
15+func cacheTheme() themeui.Theme {
16+ palette := [16]renderer.RGB{}
17+ palette[2] = renderer.RGB{R: 30, G: 190, B: 150}
18+ palette[10] = palette[2]
19+ return themeui.Theme{
20+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
21+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
22+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
23+ }
24+}
25+
26+func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) {
27+ d, sess, ac, _ := newManualSessionWithPTYs(t)
28+ raw := cacheTheme()
29+ d.applyTheme(sess, ac, ports.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})
30+ before := ac.getAppliedTheme()
31+
32+ cfg := domain.Defaults()
33+ cfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}
34+ d.ApplyConfig(cfg)
35+ after := ac.getAppliedTheme()
36+
37+ require.Equal(t, before.Raw, after.Raw, "an accent-only reload retains the definitive client palette")
38+ require.Greater(t, after.Generation, before.Generation)
39+ require.Equal(t, themeui.Resolve(after.Raw, cfg.ThemeAccent), after.Resolved)
40+ require.Equal(t, uint8(2), after.Resolved.Accent.Slot)
41+}
42+
43+func TestComposeCacheInvalidatesOnStyleGenerationWithoutPaneRecoloring(t *testing.T) {
44+ raw := cacheTheme()
45+ first := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
46+ second := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
47+ pane := renderer.NewFrame(4, 1)
48+ pane.Set(0, 0, renderer.Cell{Rune: 'P', Style: renderer.Style{Foreground: 196}})
49+ state := capturedRenderState{
50+ reset: false, theme: raw, styles: first.Styles, styleGeneration: 1,
51+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "p", valid: true, fingerprint: "same", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}}},
52+ panes: []capturedPaneRenderState{{id: "p", frame: pane, focused: true, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, damage: []renderer.Damage{renderer.FullRedraw()}}},
53+ }
54+ committed := composeFrame(state, composeCacheInput{})
55+ state.reset, state.styles, state.styleGeneration, state.panes[0].damage = false, second.Styles, 2, nil
56+ out := composeFrame(state, committed.cache, composeCacheInput{})
57+
58+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
59+ require.Equal(t, pane.At(0, 0), out.frame.At(0, 1), "captured application cells must not receive chrome styles")
60+}
61+
62+func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
63+ raw := cacheTheme()
64+ resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
65+ cell := renderer.Cell{Rune: 'X', Style: renderer.Style{Foreground: 196}}
66+ focused, inactive := renderer.NewFrame(2, 1), renderer.NewFrame(2, 1)
67+ focused.Set(0, 0, cell)
68+ inactive.Set(0, 0, cell)
69+ placements := []layout.Placement{{ID: "focused", Content: domain.Rect{Width: 2, Height: 1}}, {ID: "inactive", Content: domain.Rect{X: 2, Width: 2, Height: 1}}}
70+ state := capturedRenderState{
71+ reset: true, theme: raw, styles: resolved.Styles, styleGeneration: 1,
72+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "focused", valid: true, placements: placements},
73+ panes: []capturedPaneRenderState{{id: "focused", frame: focused, focused: true, placement: placements[0], damage: []renderer.Damage{renderer.FullRedraw()}}, {id: "inactive", frame: inactive, placement: placements[1], damage: []renderer.Damage{renderer.FullRedraw()}}},
74+ }
75+ out := composeFrame(state, composeCacheInput{})
76+ require.Equal(t, cell, out.frame.At(0, 1))
77+ require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)
78+}
M internal/usecase/daemon/theme_resolve.go +6 -1
@@ -13,6 +13,11 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1313 }
1414
1515 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
16+ // Resolve while applying, never while composing. The theme mutex only
17+ // publishes the completed value and is not held across session/tab/pane
18+ // locks, preserving the daemon lock order.
19+ applied := d.resolveAppliedTheme(t)
20+ t = applied.Raw
1621 sess.mu.Lock()
1722 if ac != nil {
1823 if sess.client != ac {
@@ -27,7 +32,7 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
2732 sess.mu.Unlock()
2833
2934 if ac != nil {
30- ac.setTheme(t)
35+ ac.setAppliedTheme(applied)
3136 }
3237 for _, tb := range tabs {
3338 tb.mu.Lock()
@@ -13,6 +13,11 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
13 }13 }
14 14
15 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {15 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
16+ // Resolve while applying, never while composing. The theme mutex only
17+ // publishes the completed value and is not held across session/tab/pane
18+ // locks, preserving the daemon lock order.
19+ applied := d.resolveAppliedTheme(t)
20+ t = applied.Raw
16 sess.mu.Lock()21 sess.mu.Lock()
17 if ac != nil {22 if ac != nil {
18 if sess.client != ac {23 if sess.client != ac {
@@ -27,7 +32,7 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
27 sess.mu.Unlock()32 sess.mu.Unlock()
28 33
29 if ac != nil {34 if ac != nil {
30- ac.setTheme(t)35+ ac.setAppliedTheme(applied)
31 }36 }
32 for _, tb := range tabs {37 for _, tb := range tabs {
33 tb.mu.Lock()38 tb.mu.Lock()

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