detached d3a09a8

fix(theme): cache neutral structural borders

commit
d3a09a88d876f3439c57913ddeb89de82890b61f
author
brice · · 2026-07-18 09:53 UTC
parent
256407f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 ...
8989 }
9090 state.styles = styles
9191 defaultDimmer := themeui.NewDimmer(state.theme)
92- neutralBorder := themeui.NeutralBorderStyle(state.theme)
92+ neutralBorder := styles.NeutralBorder
9393 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
9494 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
9595 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
@@ -89,7 +89,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
89 }89 }
90 state.styles = styles90 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
125125 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
126126 }
127127
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+
128152 func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
129153 state := cachedStackTitleState("collapsed", 1, true)
130154 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 {
160160 "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive,
161161 "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive,
162162 "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive,
163- "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar,
163+ "NeutralBorder": styles.NeutralBorder, "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar,
164164 "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection,
165165 "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc,
166166 "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 {
116116 return foregroundStyle(candidate)
117117 }
118118 }
119- return neutralBorderStyle(t)
119+ return neutralBorder(t)
120120 }
121121
122122 // 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 {
127127 return foregroundStyle(candidate)
128128 }
129129 }
130- return neutralBorderStyle(t)
130+ return neutralBorder(t)
131131 }
132132
133133 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) {
9494 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
9595 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
9696 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
97+ require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
9798 require.True(t, resolved.Styles.TabActive.Bold)
9899 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
99100 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 {
4545 SurfaceActive: ramp.SurfaceActive,
4646 BorderMuted: ramp.BorderMuted,
4747 BorderActive: ramp.BorderActive,
48+ NeutralBorder: neutralBorder(t),
4849
4950 TabInactive: ramp.SurfaceInactive,
5051 TabInactiveTitle: inactiveTitle,
@@ -114,7 +115,7 @@ func neutralStyles(t Theme) Styles {
114115 if usable(t) {
115116 status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
116117 accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
117- border = neutralBorderStyle(t)
118+ border = neutralBorder(t)
118119 // Preserve the legacy neutral muted foreground while retaining the
119120 // semantic row/bar background beneath description and separator cells.
120121 inactiveDescription = status
@@ -129,6 +130,7 @@ func neutralStyles(t Theme) Styles {
129130 SurfaceActive: accent,
130131 BorderMuted: border,
131132 BorderActive: border,
133+ NeutralBorder: border,
132134
133135 TabInactive: EmphasisStyle(status, t),
134136 TabInactiveTitle: MutedVariantStyle(status, t),
@@ -162,17 +164,16 @@ func withMRUStyles(styles Styles, ramp Ramp) Styles {
162164 return styles
163165 }
164166
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 {
168171 if !usable(t) {
169172 return renderer.DefaultStyle()
170173 }
171174 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
172175 }
173176
174-func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) }
175-
176177 func legacyMutedText(t Theme) renderer.Style {
177178 if !usable(t) {
178179 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 the119 // 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 = status121 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 styles164 return styles
163 }165 }
164 166
165-// NeutralBorderStyle is the existing non-accent structural border input for167+// 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 {
7272 SurfaceActive renderer.Style
7373 BorderMuted renderer.Style
7474 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
7579
7680 TabInactive renderer.Style
7781 TabInactiveTitle renderer.Style
@@ -72,6 +72,10 @@ type Styles struct {
72 SurfaceActive renderer.Style72 SurfaceActive renderer.Style
73 BorderMuted renderer.Style73 BorderMuted renderer.Style
74 BorderActive renderer.Style74 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.Style80 TabInactive renderer.Style
77 TabInactiveTitle renderer.Style81 TabInactiveTitle renderer.Style

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close