refactor(theme): cache resolved chrome styles
- commit
- 810e9f167016c4e8cc1e49025d15551b823eda27
- author
- brice · · 2026-07-18 08:33 UTC
- parent
- 6e468ca
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 23 | 23 | 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 | 35 | type attachedClient struct { | |
| 27 | 36 | tr ports.Transport | |
| 28 | 37 | transportIncarnation uint64 | |
| @@ -48,8 +57,9 @@ type attachedClient struct { | |||
| 48 | 57 | sess Guarded[*session] | |
| 49 | 58 | mouseScan mouse.Scanner | |
| 50 | 59 | themeMu sync.Mutex | |
| 51 | - | theme themeui.Theme | |
| 60 | + | theme themeui.Theme // compatibility mirror of applied.Raw | |
| 52 | 61 | clientTheme themeui.Theme | |
| 62 | + | appliedTheme appliedTheme | |
| 53 | 63 | lastCursor cursorOut | |
| 54 | 64 | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 55 | 65 | // previousSession is guarded independently. It is retained through temporary | |
| @@ -138,9 +148,18 @@ func (ac *attachedClient) clearCaptureFrames() { | |||
| 138 | 148 | } | |
| 139 | 149 | ||
| 140 | 150 | func (ac *attachedClient) getTheme() themeui.Theme { | |
| 151 | + | return ac.getAppliedTheme().Raw | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (ac *attachedClient) getAppliedTheme() appliedTheme { | |
| 141 | 155 | ac.themeMu.Lock() | |
| 142 | 156 | 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 | |
| 144 | 163 | } | |
| 145 | 164 | ||
| 146 | 165 | func (ac *attachedClient) getClientTheme() themeui.Theme { | |
| @@ -150,8 +169,16 @@ func (ac *attachedClient) getClientTheme() themeui.Theme { | |||
| 150 | 169 | } | |
| 151 | 170 | ||
| 152 | 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 | 178 | ac.themeMu.Lock() | |
| 154 | - | ac.theme = t | |
| 179 | + | next.Generation = ac.appliedTheme.Generation + 1 | |
| 180 | + | ac.appliedTheme = next | |
| 181 | + | ac.theme = next.Raw | |
| 155 | 182 | ac.themeMu.Unlock() | |
| 156 | 183 | } | |
| 157 | 184 | ||
| @@ -432,7 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||
| 432 | 459 | sess.mu.Unlock() | |
| 433 | 460 | d.touchMRU(sess) | |
| 434 | 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 | 463 | return ac, old, cleanup | |
| 437 | 464 | } | |
| 438 | 465 | ||
| @@ -526,7 +553,7 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached | |||
| 526 | 553 | // (and run its own attach-time reset), so this call must leave the tabs | |
| 527 | 554 | // alone rather than clobbering that client's freshly applied colors. | |
| 528 | 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 | 559 | // 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 | 737 | Light: msg.Light, | |
| 711 | 738 | } | |
| 712 | 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 | 742 | return | |
| 717 | 743 | } | |
| 718 | 744 | d.invalidateRender(sess, ac, true, "client.go") | |
| @@ -23,6 +23,15 @@ type transportSnapshot struct { | |||||
| 23 | incarnation uint64 | 23 | 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.Transport | 36 | tr ports.Transport | ||
| 28 | transportIncarnation uint64 | 37 | transportIncarnation uint64 | ||
| @@ -48,8 +57,9 @@ type attachedClient struct { | |||||
| 48 | sess Guarded[*session] | 57 | sess Guarded[*session] | ||
| 49 | mouseScan mouse.Scanner | 58 | mouseScan mouse.Scanner | ||
| 50 | themeMu sync.Mutex | 59 | themeMu sync.Mutex | ||
| 51 | - | theme themeui.Theme | 60 | + | theme themeui.Theme // compatibility mirror of applied.Raw |
| 52 | clientTheme themeui.Theme | 61 | clientTheme themeui.Theme | ||
| 62 | + | appliedTheme appliedTheme | |||
| 53 | lastCursor cursorOut | 63 | lastCursor cursorOut | ||
| 54 | renderStages renderStageHooks // optional render and handoff observability hooks | 64 | renderStages renderStageHooks // optional render and handoff observability hooks | ||
| 55 | // previousSession is guarded independently. It is retained through temporary | 65 | // 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.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 | |||
| 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 = t | 179 | + | 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, cleanup | 463 | 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 tabs | 553 | // (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-worker | 559 | // 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 | return | 742 | 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}$`) | |||
| 21 | 21 | type themeConfigSnapshot struct { | |
| 22 | 22 | mode domain.ThemeMode | |
| 23 | 23 | paletteOff bool | |
| 24 | + | accent domain.ThemeAccent | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 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 | 29 | d.themeConfig.Store(&snapshot) | |
| 29 | 30 | } | |
| 30 | 31 | ||
| @@ -259,8 +260,7 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | |||
| 259 | 260 | return command.Command{}, false | |
| 260 | 261 | } | |
| 261 | 262 | ||
| 262 | - | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |
| 263 | - | config := d.currentThemeConfig() | |
| 263 | + | func effectiveThemeForConfig(clientTheme theme.Theme, config themeConfigSnapshot) theme.Theme { | |
| 264 | 264 | switch config.mode { | |
| 265 | 265 | case domain.ThemeDark: | |
| 266 | 266 | 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 | 285 | func (d *Daemon) reapplyThemeAllSessions() { | |
| 276 | 286 | d.mu.Lock() | |
| 277 | 287 | sessions := make([]*session, 0, len(d.sessions)) | |
| @@ -290,12 +300,12 @@ func (d *Daemon) reapplyThemeSession(sess *session) { | |||
| 290 | 300 | ac := sess.client | |
| 291 | 301 | sess.mu.Unlock() | |
| 292 | 302 | if ac == nil { | |
| 293 | - | d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true) | |
| 303 | + | d.applyHostTheme(sess, nil, theme.Theme{}, true) | |
| 294 | 304 | return | |
| 295 | 305 | } | |
| 296 | 306 | ac.sendMu.Lock() | |
| 297 | 307 | defer ac.sendMu.Unlock() | |
| 298 | 308 | sess.themeMu.Lock() | |
| 299 | 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 | } | |
| @@ -21,10 +21,11 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | |||||
| 21 | type themeConfigSnapshot struct { | 21 | type themeConfigSnapshot struct { | ||
| 22 | mode domain.ThemeMode | 22 | mode domain.ThemeMode | ||
| 23 | paletteOff bool | 23 | 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{}, false | 260 | 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.BuiltinDark | 266 | 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.client | 300 | 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 | return | 304 | 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) { | |||
| 412 | 412 | func isCopyEscapePrefix(data []byte) bool { | |
| 413 | 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 | 416 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 417 | 417 | return frame, nil | |
| 418 | 418 | } | |
| 419 | - | styles := themeui.NewStyles(bars.theme) | |
| 420 | 419 | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) | |
| 421 | 420 | bodyRows := max(copyFrame.Height-1, 0) | |
| 422 | 421 | 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, nil | 417 | 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) { | |||
| 115 | 115 | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | - | bars := barState{status: sess.statusSegments(true)} | |
| 119 | 118 | ||
| 120 | 119 | base := renderer.NewFrame(80, 25) | |
| 121 | 120 | target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23} | |
| @@ -133,7 +132,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 133 | 132 | go func() { | |
| 134 | 133 | defer wg.Done() | |
| 135 | 134 | for range 200 { | |
| 136 | - | _, _ = composeCopyClientFrame(mode, target, base, bars) | |
| 135 | + | _, _ = composeCopyClientFrame(mode, target, base, resolveStyles(nil)) | |
| 137 | 136 | } | |
| 138 | 137 | }() | |
| 139 | 138 | wg.Wait() | |
| @@ -160,7 +159,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 160 | 159 | }}, | |
| 161 | 160 | bars: bars, | |
| 162 | 161 | }, 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 | 164 | require.Equal(t, 80, frame.Width) | |
| 166 | 165 | 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{}).frame | 161 | }, 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 { | |||
| 92 | 92 | content domain.Rect | |
| 93 | 93 | layout capturedTabLayout | |
| 94 | 94 | theme themeui.Theme | |
| 95 | + | styles themeui.Styles | |
| 95 | 96 | cache composeCacheInput | |
| 96 | 97 | full bool | |
| 97 | 98 | } | |
| @@ -103,6 +104,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 103 | 104 | content := input.content | |
| 104 | 105 | layoutSnap := input.layout | |
| 105 | 106 | theme := input.theme | |
| 107 | + | styles := input.styles | |
| 106 | 108 | cache := input.cache | |
| 107 | 109 | full := input.full | |
| 108 | 110 | ||
| @@ -117,7 +119,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 117 | 119 | } | |
| 118 | 120 | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | |
| 119 | 121 | 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 | 123 | if full || popupChanged { | |
| 122 | 124 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 123 | 125 | } | |
| @@ -92,6 +92,7 @@ type floatingComposeInput struct { | |||||
| 92 | content domain.Rect | 92 | content domain.Rect | ||
| 93 | layout capturedTabLayout | 93 | layout capturedTabLayout | ||
| 94 | theme themeui.Theme | 94 | theme themeui.Theme | ||
| 95 | + | styles themeui.Styles | |||
| 95 | cache composeCacheInput | 96 | cache composeCacheInput | ||
| 96 | full bool | 97 | full bool | ||
| 97 | } | 98 | } | ||
| @@ -103,6 +104,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 103 | content := input.content | 104 | content := input.content | ||
| 104 | layoutSnap := input.layout | 105 | layoutSnap := input.layout | ||
| 105 | theme := input.theme | 106 | theme := input.theme | ||
| 107 | + | styles := input.styles | |||
| 106 | cache := input.cache | 108 | cache := input.cache | ||
| 107 | full := input.full | 109 | 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 != geometry | 120 | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | ||
| 119 | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | 121 | 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 | |||
| 653 | 653 | t.Run(tt.name, func(t *testing.T) { | |
| 654 | 654 | state := capturedRenderState{ | |
| 655 | 655 | theme: tt.theme, | |
| 656 | + | styles: themeui.Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | |
| 656 | 657 | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |
| 657 | 658 | } | |
| 658 | 659 | snap := &overlayRenderSnapshot{ | |
| @@ -665,7 +666,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T | |||
| 665 | 666 | inner := state.overlays.picker.inner | |
| 666 | 667 | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 667 | 668 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | |
| 668 | - | want := themeui.MutedTextStyle(tt.theme) | |
| 669 | + | want := state.styles.PickerSeparator | |
| 669 | 670 | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | |
| 670 | 671 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |
| 671 | 672 | cell := inner.At(x, y) | |
| @@ -679,7 +680,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T | |||
| 679 | 680 | ||
| 680 | 681 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | |
| 681 | 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 | 684 | cases := []struct { | |
| 684 | 685 | name string | |
| 685 | 686 | 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.inner | 666 | 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 string | 685 | name string | ||
| 685 | size domain.Size | 686 | 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 | |||
| 295 | 295 | // Contextual ranks are completely captured under paletteMu. Compose them | |
| 296 | 296 | // without reading the live MRU, whose order may have changed mid-interaction. | |
| 297 | 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 | 300 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 300 | 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 | 328 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 328 | 329 | 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, | |
| 335 | 338 | }) | |
| 336 | 339 | endCapture(0, ok) | |
| 337 | 340 | if !ok { | |
| @@ -357,11 +360,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 357 | 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 | 365 | func resolveStyles(styles []themeui.Styles) themeui.Styles { | |
| 361 | 366 | if len(styles) > 0 { | |
| 362 | 367 | return styles[0] | |
| 363 | 368 | } | |
| 364 | - | return themeui.NewStyles(themeui.Theme{}) | |
| 369 | + | return fallbackChromeStyles | |
| 365 | 370 | } | |
| 366 | 371 | ||
| 367 | 372 | 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 them | 295 | // 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 { | |||
| 43 | 43 | floating capturedFloatingRenderState | |
| 44 | 44 | bars barState | |
| 45 | 45 | theme themeui.Theme | |
| 46 | + | styles themeui.Styles | |
| 47 | + | styleGeneration uint64 | |
| 46 | 48 | overlays capturedOverlayRenderState | |
| 47 | 49 | preview picker.Preview | |
| 48 | 50 | cursor capturedCursorInputs | |
| @@ -166,12 +168,14 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |||
| 166 | 168 | } | |
| 167 | 169 | ||
| 168 | 170 | 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 | |
| 175 | 179 | } | |
| 176 | 180 | ||
| 177 | 181 | // capturePrimaryRenderState is the ownership boundary for a primary render | |
| @@ -227,6 +231,7 @@ func capturePrimaryRenderState( | |||
| 227 | 231 | state := &scratch.state | |
| 228 | 232 | *state = capturedRenderState{ | |
| 229 | 233 | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | |
| 234 | + | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 230 | 235 | overlays: overlays, preview: preview, | |
| 231 | 236 | layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 232 | 237 | floatingGeneration: tb.floating.generation, | |
| @@ -43,6 +43,8 @@ type capturedRenderState struct { | |||||
| 43 | floating capturedFloatingRenderState | 43 | floating capturedFloatingRenderState | ||
| 44 | bars barState | 44 | bars barState | ||
| 45 | theme themeui.Theme | 45 | theme themeui.Theme | ||
| 46 | + | styles themeui.Styles | |||
| 47 | + | styleGeneration uint64 | |||
| 46 | overlays capturedOverlayRenderState | 48 | overlays capturedOverlayRenderState | ||
| 47 | preview picker.Preview | 49 | preview picker.Preview | ||
| 48 | cursor capturedCursorInputs | 50 | 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 barState | 171 | + | bars barState |
| 170 | - | overlays capturedOverlayRenderState | 172 | + | overlays capturedOverlayRenderState |
| 171 | - | preview picker.Preview | 173 | + | preview picker.Preview |
| 172 | - | floatingCfg domain.FloatingConfig | 174 | + | floatingCfg domain.FloatingConfig |
| 173 | - | reset bool | 175 | + | styles themeui.Styles |
| 174 | - | lease *attachmentLease | 176 | + | styleGeneration uint64 |
| 177 | + | reset bool | |||
| 178 | + | lease *attachmentLease | |||
| 175 | } | 179 | } | ||
| 176 | 180 | ||||
| 177 | // capturePrimaryRenderState is the ownership boundary for a primary render | 181 | // capturePrimaryRenderState is the ownership boundary for a primary render | ||
| @@ -227,6 +231,7 @@ func capturePrimaryRenderState( | |||||
| 227 | state := &scratch.state | 231 | 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 { | |||
| 41 | 41 | floatingTitleGeneration uint64 | |
| 42 | 42 | bars barCache | |
| 43 | 43 | theme themeui.Theme | |
| 44 | + | styleGeneration uint64 | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | type composedRenderFrame struct { | |
| @@ -77,7 +78,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 77 | 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 | 88 | defaultDimmer := themeui.NewDimmer(state.theme) | |
| 82 | 89 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | |
| 83 | 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 | 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.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 | |
| 91 | 98 | titles := scratch.titleGenerations | |
| 92 | 99 | if titles == nil { | |
| 93 | 100 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | |
| @@ -125,6 +132,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 125 | 132 | content: content, | |
| 126 | 133 | layout: state.layout, | |
| 127 | 134 | theme: state.theme, | |
| 135 | + | styles: styles, | |
| 128 | 136 | cache: in, | |
| 129 | 137 | full: full || state.overlays.active(), | |
| 130 | 138 | }) | |
| @@ -152,7 +160,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 152 | 160 | cursorInputs := state.cursor | |
| 153 | 161 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | |
| 154 | 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 | 164 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 157 | 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 | 199 | if snap.copyPane != nil { | |
| 192 | 200 | 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 | 206 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 196 | 207 | if snap.copySearchModel != nil { | |
| 197 | 208 | 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 | 254 | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |
| 244 | 255 | if o.paletteActive && !state.floating.visible { | |
| @@ -248,7 +259,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 248 | 259 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |
| 249 | 260 | continue | |
| 250 | 261 | } | |
| 251 | - | inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border) | |
| 262 | + | inner := modal.modal.Composite(frame, state.styles.BorderMuted) | |
| 252 | 263 | for y := range min(inner.Height, modal.inner.Height) { | |
| 253 | 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 | } | |
| @@ -41,6 +41,7 @@ type composeCacheInput struct { | |||||
| 41 | floatingTitleGeneration uint64 | 41 | floatingTitleGeneration uint64 | ||
| 42 | bars barCache | 42 | bars barCache | ||
| 43 | theme themeui.Theme | 43 | 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.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 |
| 91 | titles := scratch.titleGenerations | 98 | 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.cursor | 160 | 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.id | 200 | 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 = copySearchModal | 208 | 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 | continue | 260 | 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) { | |||
| 135 | 135 | Foreground: renderer.RGB{R: 220, G: 210, B: 200}, | |
| 136 | 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 | 139 | before := cloneCapturedStackState(state) | |
| 139 | 140 | ||
| 140 | 141 | out := composeFrame(state, composeCacheInput{}) | |
| @@ -208,6 +209,7 @@ func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout. | |||
| 208 | 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 | 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 | 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 | 223 | reset: reset, | |
| 222 | 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 | 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 | 244 | func cacheBarState(bottom string, reset bool) capturedRenderState { | |
| 242 | 245 | pane := cachePaneFrame(6, 1, 'P') | |
| 243 | 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 | 250 | func cachePaneFrame(width, height int, r rune) renderer.Frame { | |
| @@ -263,6 +266,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||
| 263 | 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 | 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 | 268 | bars: barState{topRight: title, bottomRight: title}, | |
| 269 | + | styles: resolveStyles(nil), | |
| 266 | 270 | } | |
| 267 | 271 | } | |
| 268 | 272 | ||
| @@ -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) { | |||
| 223 | 223 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | |
| 224 | 224 | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}}, | |
| 225 | 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 | 228 | composed := composeFrame(state, composeCacheInput{}) | |
| 228 | 229 | 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) { | |||
| 1178 | 1178 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1179 | 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 | 1183 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 1184 | 1184 | 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) { | |||
| 417 | 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 | 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 | 424 | }, composeCacheInput{}) | |
| 424 | 425 | out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage) | |
| 425 | 426 | ||
| @@ -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 | |||
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | 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 | 21 | sess.mu.Lock() | |
| 17 | 22 | if ac != nil { | |
| 18 | 23 | if sess.client != ac { | |
| @@ -27,7 +32,7 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||
| 27 | 32 | sess.mu.Unlock() | |
| 28 | 33 | ||
| 29 | 34 | if ac != nil { | |
| 30 | - | ac.setTheme(t) | |
| 35 | + | ac.setAppliedTheme(applied) | |
| 31 | 36 | } | |
| 32 | 37 | for _, tb := range tabs { | |
| 33 | 38 | 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() | ||