fix(theme): cache neutral structural borders
- commit
- d3a09a88d876f3439c57913ddeb89de82890b61f
- author
- brice · · 2026-07-18 09:53 UTC
- parent
- 256407f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +40 -10
M internal/usecase/daemon/render_pipeline.go +1 -1
| @@ -89,7 +89,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 89 | 89 | } | |
| 90 | 90 | state.styles = styles | |
| 91 | 91 | defaultDimmer := themeui.NewDimmer(state.theme) | |
| 92 | - | neutralBorder := themeui.NeutralBorderStyle(state.theme) | |
| 92 | + | neutralBorder := styles.NeutralBorder | |
| 93 | 93 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | |
| 94 | 94 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 95 | 95 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| @@ -89,7 +89,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 89 | } | 89 | } | ||
| 90 | state.styles = styles | 90 | state.styles = styles | ||
| 91 | defaultDimmer := themeui.NewDimmer(state.theme) | 91 | defaultDimmer := themeui.NewDimmer(state.theme) | ||
| 92 | - | neutralBorder := themeui.NeutralBorderStyle(state.theme) | 92 | + | neutralBorder := styles.NeutralBorder |
| 93 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | 93 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | ||
| 94 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | 94 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | ||
| 95 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | 95 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +24 -0
| @@ -125,6 +125,30 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes | |||
| 125 | 125 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | |
| 129 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | |
| 130 | + | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | |
| 131 | + | expected := themeui.NewDimmer(theme).Dim(neutralBorder) | |
| 132 | + | ||
| 133 | + | t.Run("divider", func(t *testing.T) { | |
| 134 | + | state := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | |
| 135 | + | state.styles.NeutralBorder = neutralBorder | |
| 136 | + | ||
| 137 | + | out := composeFrame(state, composeCacheInput{}) | |
| 138 | + | require.Equal(t, expected, out.frame.At(20, 1).Style) | |
| 139 | + | }) | |
| 140 | + | ||
| 141 | + | t.Run("unfocused title", func(t *testing.T) { | |
| 142 | + | state := cachedStackTitleState("collapsed", 1, true) | |
| 143 | + | state.theme = theme | |
| 144 | + | state.styles = themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 145 | + | state.styles.NeutralBorder = neutralBorder | |
| 146 | + | ||
| 147 | + | out := composeFrame(state, composeCacheInput{}) | |
| 148 | + | require.Equal(t, expected, out.frame.At(0, 1).Style) | |
| 149 | + | }) | |
| 150 | + | } | |
| 151 | + | ||
| 128 | 152 | func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |
| 129 | 153 | state := cachedStackTitleState("collapsed", 1, true) | |
| 130 | 154 | state.theme = themeui.Theme{ | |
| @@ -125,6 +125,30 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes | |||||
| 125 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | 125 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | |||
| 129 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | |||
| 130 | + | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | |||
| 131 | + | expected := themeui.NewDimmer(theme).Dim(neutralBorder) | |||
| 132 | + | ||||
| 133 | + | t.Run("divider", func(t *testing.T) { | |||
| 134 | + | state := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | |||
| 135 | + | state.styles.NeutralBorder = neutralBorder | |||
| 136 | + | ||||
| 137 | + | out := composeFrame(state, composeCacheInput{}) | |||
| 138 | + | require.Equal(t, expected, out.frame.At(20, 1).Style) | |||
| 139 | + | }) | |||
| 140 | + | ||||
| 141 | + | t.Run("unfocused title", func(t *testing.T) { | |||
| 142 | + | state := cachedStackTitleState("collapsed", 1, true) | |||
| 143 | + | state.theme = theme | |||
| 144 | + | state.styles = themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |||
| 145 | + | state.styles.NeutralBorder = neutralBorder | |||
| 146 | + | ||||
| 147 | + | out := composeFrame(state, composeCacheInput{}) | |||
| 148 | + | require.Equal(t, expected, out.frame.At(0, 1).Style) | |||
| 149 | + | }) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 128 | func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | 152 | func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | ||
| 129 | state := cachedStackTitleState("collapsed", 1, true) | 153 | state := cachedStackTitleState("collapsed", 1, true) | ||
| 130 | state.theme = themeui.Theme{ | 154 | state.theme = themeui.Theme{ | ||
M internal/usecase/theme/phase3_review_test.go +1 -1
| @@ -160,7 +160,7 @@ func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style { | |||
| 160 | 160 | "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive, | |
| 161 | 161 | "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive, | |
| 162 | 162 | "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive, | |
| 163 | - | "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar, | |
| 163 | + | "NeutralBorder": styles.NeutralBorder, "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar, | |
| 164 | 164 | "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection, | |
| 165 | 165 | "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc, | |
| 166 | 166 | "TabName": styles.TabName, "TabNameActive": styles.TabNameActive, | |
| @@ -160,7 +160,7 @@ func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style { | |||||
| 160 | "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive, | 160 | "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive, | ||
| 161 | "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive, | 161 | "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive, | ||
| 162 | "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive, | 162 | "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive, | ||
| 163 | - | "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar, | 163 | + | "NeutralBorder": styles.NeutralBorder, "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar, |
| 164 | "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection, | 164 | "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection, | ||
| 165 | "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc, | 165 | "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc, | ||
| 166 | "TabName": styles.TabName, "TabNameActive": styles.TabNameActive, | 166 | "TabName": styles.TabName, "TabNameActive": styles.TabNameActive, | ||
M internal/usecase/theme/ramp.go +2 -2
| @@ -116,7 +116,7 @@ func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||
| 116 | 116 | return foregroundStyle(candidate) | |
| 117 | 117 | } | |
| 118 | 118 | } | |
| 119 | - | return neutralBorderStyle(t) | |
| 119 | + | return neutralBorder(t) | |
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | 122 | // activeBorder starts at the accent endpoint and reduces only when needed. | |
| @@ -127,7 +127,7 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||
| 127 | 127 | return foregroundStyle(candidate) | |
| 128 | 128 | } | |
| 129 | 129 | } | |
| 130 | - | return neutralBorderStyle(t) | |
| 130 | + | return neutralBorder(t) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | 133 | func foregroundStyle(color renderer.RGB) renderer.Style { | |
| @@ -116,7 +116,7 @@ func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||||
| 116 | return foregroundStyle(candidate) | 116 | return foregroundStyle(candidate) | ||
| 117 | } | 117 | } | ||
| 118 | } | 118 | } | ||
| 119 | - | return neutralBorderStyle(t) | 119 | + | return neutralBorder(t) |
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | // activeBorder starts at the accent endpoint and reduces only when needed. | 122 | // activeBorder starts at the accent endpoint and reduces only when needed. | ||
| @@ -127,7 +127,7 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||||
| 127 | return foregroundStyle(candidate) | 127 | return foregroundStyle(candidate) | ||
| 128 | } | 128 | } | ||
| 129 | } | 129 | } | ||
| 130 | - | return neutralBorderStyle(t) | 130 | + | return neutralBorder(t) |
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | func foregroundStyle(color renderer.RGB) renderer.Style { | 133 | func foregroundStyle(color renderer.RGB) renderer.Style { | ||
M internal/usecase/theme/ramp_test.go +1 -0
| @@ -94,6 +94,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||
| 94 | 94 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | |
| 95 | 95 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | |
| 96 | 96 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | |
| 97 | + | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | |
| 97 | 98 | require.True(t, resolved.Styles.TabActive.Bold) | |
| 98 | 99 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | |
| 99 | 100 | require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase) | |
| @@ -94,6 +94,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||||
| 94 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | 94 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | ||
| 95 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | 95 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | ||
| 96 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | 96 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | ||
| 97 | + | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | |||
| 97 | require.True(t, resolved.Styles.TabActive.Bold) | 98 | require.True(t, resolved.Styles.TabActive.Bold) | ||
| 98 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | 99 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | ||
| 99 | require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase) | 100 | require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase) | ||
M internal/usecase/theme/resolved_styles.go +7 -6
| @@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 45 | 45 | SurfaceActive: ramp.SurfaceActive, | |
| 46 | 46 | BorderMuted: ramp.BorderMuted, | |
| 47 | 47 | BorderActive: ramp.BorderActive, | |
| 48 | + | NeutralBorder: neutralBorder(t), | |
| 48 | 49 | ||
| 49 | 50 | TabInactive: ramp.SurfaceInactive, | |
| 50 | 51 | TabInactiveTitle: inactiveTitle, | |
| @@ -114,7 +115,7 @@ func neutralStyles(t Theme) Styles { | |||
| 114 | 115 | if usable(t) { | |
| 115 | 116 | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |
| 116 | 117 | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |
| 117 | - | border = neutralBorderStyle(t) | |
| 118 | + | border = neutralBorder(t) | |
| 118 | 119 | // Preserve the legacy neutral muted foreground while retaining the | |
| 119 | 120 | // semantic row/bar background beneath description and separator cells. | |
| 120 | 121 | inactiveDescription = status | |
| @@ -129,6 +130,7 @@ func neutralStyles(t Theme) Styles { | |||
| 129 | 130 | SurfaceActive: accent, | |
| 130 | 131 | BorderMuted: border, | |
| 131 | 132 | BorderActive: border, | |
| 133 | + | NeutralBorder: border, | |
| 132 | 134 | ||
| 133 | 135 | TabInactive: EmphasisStyle(status, t), | |
| 134 | 136 | TabInactiveTitle: MutedVariantStyle(status, t), | |
| @@ -162,17 +164,16 @@ func withMRUStyles(styles Styles, ramp Ramp) Styles { | |||
| 162 | 164 | return styles | |
| 163 | 165 | } | |
| 164 | 166 | ||
| 165 | - | // NeutralBorderStyle is the existing non-accent structural border input for | |
| 166 | - | // dimmed pane chrome. It intentionally does not consume a resolved accent. | |
| 167 | - | func NeutralBorderStyle(t Theme) renderer.Style { | |
| 167 | + | // neutralBorder is the existing non-accent structural border input for pane | |
| 168 | + | // chrome. It intentionally does not consume a resolved accent and is called | |
| 169 | + | // only while resolving an immutable style snapshot. | |
| 170 | + | func neutralBorder(t Theme) renderer.Style { | |
| 168 | 171 | if !usable(t) { | |
| 169 | 172 | return renderer.DefaultStyle() | |
| 170 | 173 | } | |
| 171 | 174 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |
| 172 | 175 | } | |
| 173 | 176 | ||
| 174 | - | func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) } | |
| 175 | - | ||
| 176 | 177 | func legacyMutedText(t Theme) renderer.Style { | |
| 177 | 178 | if !usable(t) { | |
| 178 | 179 | return renderer.DefaultStyle() | |
| @@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 45 | SurfaceActive: ramp.SurfaceActive, | 45 | SurfaceActive: ramp.SurfaceActive, | ||
| 46 | BorderMuted: ramp.BorderMuted, | 46 | BorderMuted: ramp.BorderMuted, | ||
| 47 | BorderActive: ramp.BorderActive, | 47 | BorderActive: ramp.BorderActive, | ||
| 48 | + | NeutralBorder: neutralBorder(t), | |||
| 48 | 49 | ||||
| 49 | TabInactive: ramp.SurfaceInactive, | 50 | TabInactive: ramp.SurfaceInactive, | ||
| 50 | TabInactiveTitle: inactiveTitle, | 51 | TabInactiveTitle: inactiveTitle, | ||
| @@ -114,7 +115,7 @@ func neutralStyles(t Theme) Styles { | |||||
| 114 | if usable(t) { | 115 | if usable(t) { | ||
| 115 | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | 116 | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | ||
| 116 | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | 117 | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | ||
| 117 | - | border = neutralBorderStyle(t) | 118 | + | border = neutralBorder(t) |
| 118 | // Preserve the legacy neutral muted foreground while retaining the | 119 | // Preserve the legacy neutral muted foreground while retaining the | ||
| 119 | // semantic row/bar background beneath description and separator cells. | 120 | // semantic row/bar background beneath description and separator cells. | ||
| 120 | inactiveDescription = status | 121 | inactiveDescription = status | ||
| @@ -129,6 +130,7 @@ func neutralStyles(t Theme) Styles { | |||||
| 129 | SurfaceActive: accent, | 130 | SurfaceActive: accent, | ||
| 130 | BorderMuted: border, | 131 | BorderMuted: border, | ||
| 131 | BorderActive: border, | 132 | BorderActive: border, | ||
| 133 | + | NeutralBorder: border, | |||
| 132 | 134 | ||||
| 133 | TabInactive: EmphasisStyle(status, t), | 135 | TabInactive: EmphasisStyle(status, t), | ||
| 134 | TabInactiveTitle: MutedVariantStyle(status, t), | 136 | TabInactiveTitle: MutedVariantStyle(status, t), | ||
| @@ -162,17 +164,16 @@ func withMRUStyles(styles Styles, ramp Ramp) Styles { | |||||
| 162 | return styles | 164 | return styles | ||
| 163 | } | 165 | } | ||
| 164 | 166 | ||||
| 165 | - | // NeutralBorderStyle is the existing non-accent structural border input for | 167 | + | // neutralBorder is the existing non-accent structural border input for pane |
| 166 | - | // dimmed pane chrome. It intentionally does not consume a resolved accent. | 168 | + | // chrome. It intentionally does not consume a resolved accent and is called |
| 167 | - | func NeutralBorderStyle(t Theme) renderer.Style { | 169 | + | // only while resolving an immutable style snapshot. |
| 170 | + | func neutralBorder(t Theme) renderer.Style { | |||
| 168 | if !usable(t) { | 171 | if !usable(t) { | ||
| 169 | return renderer.DefaultStyle() | 172 | return renderer.DefaultStyle() | ||
| 170 | } | 173 | } | ||
| 171 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | 174 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | ||
| 172 | } | 175 | } | ||
| 173 | 176 | ||||
| 174 | - | func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) } | |||
| 175 | - | ||||
| 176 | func legacyMutedText(t Theme) renderer.Style { | 177 | func legacyMutedText(t Theme) renderer.Style { | ||
| 177 | if !usable(t) { | 178 | if !usable(t) { | ||
| 178 | return renderer.DefaultStyle() | 179 | return renderer.DefaultStyle() | ||
M internal/usecase/theme/styles.go +4 -0
| @@ -72,6 +72,10 @@ type Styles struct { | |||
| 72 | 72 | SurfaceActive renderer.Style | |
| 73 | 73 | BorderMuted renderer.Style | |
| 74 | 74 | BorderActive renderer.Style | |
| 75 | + | // NeutralBorder is the non-accent structural border for pane dividers and | |
| 76 | + | // unfocused pane title bars. It is resolved with the rest of the immutable | |
| 77 | + | // chrome snapshot so composition never derives theme colors. | |
| 78 | + | NeutralBorder renderer.Style | |
| 75 | 79 | ||
| 76 | 80 | TabInactive renderer.Style | |
| 77 | 81 | TabInactiveTitle renderer.Style | |
| @@ -72,6 +72,10 @@ type Styles struct { | |||||
| 72 | SurfaceActive renderer.Style | 72 | SurfaceActive renderer.Style | ||
| 73 | BorderMuted renderer.Style | 73 | BorderMuted renderer.Style | ||
| 74 | BorderActive renderer.Style | 74 | BorderActive renderer.Style | ||
| 75 | + | // NeutralBorder is the non-accent structural border for pane dividers and | |||
| 76 | + | // unfocused pane title bars. It is resolved with the rest of the immutable | |||
| 77 | + | // chrome snapshot so composition never derives theme colors. | |||
| 78 | + | NeutralBorder renderer.Style | |||
| 75 | 79 | ||||
| 76 | TabInactive renderer.Style | 80 | TabInactive renderer.Style | ||
| 77 | TabInactiveTitle renderer.Style | 81 | TabInactiveTitle renderer.Style | ||