fix(theme): resolve accent render review findings
- commit
- afb8c1abf2c3ed977851d9f7795380d50b0691db
- author
- brice · · 2026-07-18 11:40 UTC
- parent
- c5fa06a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +69 -26
M internal/usecase/daemon/copymode.go +1 -1
| @@ -416,7 +416,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||
| 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 | - | copyFrame := mode.Render(styles.SurfaceBar, styles.Selection) | |
| 419 | + | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) | |
| 420 | 420 | bodyRows := max(copyFrame.Height-1, 0) | |
| 421 | 421 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | |
| 422 | 422 | dstX := max(target.X, 0) | |
| @@ -416,7 +416,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||||
| 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 | - | copyFrame := mode.Render(styles.SurfaceBar, styles.Selection) | 419 | + | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) |
| 420 | bodyRows := max(copyFrame.Height-1, 0) | 420 | bodyRows := max(copyFrame.Height-1, 0) | ||
| 421 | 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++ { | ||
| 422 | dstX := max(target.X, 0) | 422 | dstX := max(target.X, 0) | ||
M internal/usecase/daemon/copymode_test.go +31 -13
| @@ -881,18 +881,36 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 881 | 881 | require.Equal(t, want, string(msg.Data)) | |
| 882 | 882 | } | |
| 883 | 883 | ||
| 884 | - | func TestComposeCopyClientFrameFillsFullStatusRowForSplitPane(t *testing.T) { | |
| 885 | - | base := renderer.NewFrame(20, 8) | |
| 886 | - | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 887 | - | pane.screen.Write([]byte("copy")) | |
| 888 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | |
| 889 | - | styles := resolveStyles(nil) | |
| 890 | - | styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3} | |
| 891 | - | ||
| 892 | - | frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles) | |
| 893 | - | ||
| 894 | - | for x, cell := range frame.Row(frame.Height - 1) { | |
| 895 | - | require.Truef(t, cell.Style.Equal(styles.SurfaceBar), "status cell %d must retain the full-width SurfaceBar role", x) | |
| 884 | + | func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |
| 885 | + | for _, tt := range []struct { | |
| 886 | + | name string | |
| 887 | + | selectMode bool | |
| 888 | + | want string | |
| 889 | + | }{ | |
| 890 | + | {name: "scroll", want: "[SCROLL]"}, | |
| 891 | + | {name: "selection", selectMode: true, want: "[SELECT]"}, | |
| 892 | + | } { | |
| 893 | + | t.Run(tt.name, func(t *testing.T) { | |
| 894 | + | base := renderer.NewFrame(20, 8) | |
| 895 | + | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 896 | + | pane.screen.Write([]byte("copy")) | |
| 897 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | |
| 898 | + | if tt.selectMode { | |
| 899 | + | mode.ToggleLineSelection() | |
| 900 | + | } | |
| 901 | + | styles := resolveStyles(nil) | |
| 902 | + | styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3} | |
| 903 | + | styles.CopyStatus = renderer.Style{Foreground: 5, Background: 4} | |
| 904 | + | ||
| 905 | + | frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles) | |
| 906 | + | status := frame.Row(frame.Height - 1) | |
| 907 | + | for x := range 12 { | |
| 908 | + | require.Truef(t, status[x].Style.Equal(styles.CopyStatus), "status content cell %d must use CopyStatus", x) | |
| 909 | + | } | |
| 910 | + | for x := 12; x < len(status); x++ { | |
| 911 | + | require.Truef(t, status[x].Style.Equal(styles.SurfaceBar), "surrounding status cell %d must use SurfaceBar", x) | |
| 912 | + | } | |
| 913 | + | require.Contains(t, rowText(status), tt.want) | |
| 914 | + | }) | |
| 896 | 915 | } | |
| 897 | - | require.Contains(t, rowText(frame.Row(frame.Height-1)), "[SCROLL]") | |
| 898 | 916 | } | |
| @@ -881,18 +881,36 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||||
| 881 | require.Equal(t, want, string(msg.Data)) | 881 | require.Equal(t, want, string(msg.Data)) | ||
| 882 | } | 882 | } | ||
| 883 | 883 | ||||
| 884 | - | func TestComposeCopyClientFrameFillsFullStatusRowForSplitPane(t *testing.T) { | 884 | + | func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { |
| 885 | - | base := renderer.NewFrame(20, 8) | 885 | + | for _, tt := range []struct { |
| 886 | - | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | 886 | + | name string |
| 887 | - | pane.screen.Write([]byte("copy")) | 887 | + | selectMode bool |
| 888 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | 888 | + | want string |
| 889 | - | styles := resolveStyles(nil) | 889 | + | }{ |
| 890 | - | styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3} | 890 | + | {name: "scroll", want: "[SCROLL]"}, |
| 891 | - | 891 | + | {name: "selection", selectMode: true, want: "[SELECT]"}, | |
| 892 | - | frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles) | 892 | + | } { |
| 893 | - | 893 | + | t.Run(tt.name, func(t *testing.T) { | |
| 894 | - | for x, cell := range frame.Row(frame.Height - 1) { | 894 | + | base := renderer.NewFrame(20, 8) |
| 895 | - | require.Truef(t, cell.Style.Equal(styles.SurfaceBar), "status cell %d must retain the full-width SurfaceBar role", x) | 895 | + | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) |
| 896 | + | pane.screen.Write([]byte("copy")) | |||
| 897 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | |||
| 898 | + | if tt.selectMode { | |||
| 899 | + | mode.ToggleLineSelection() | |||
| 900 | + | } | |||
| 901 | + | styles := resolveStyles(nil) | |||
| 902 | + | styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3} | |||
| 903 | + | styles.CopyStatus = renderer.Style{Foreground: 5, Background: 4} | |||
| 904 | + | ||||
| 905 | + | frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles) | |||
| 906 | + | status := frame.Row(frame.Height - 1) | |||
| 907 | + | for x := range 12 { | |||
| 908 | + | require.Truef(t, status[x].Style.Equal(styles.CopyStatus), "status content cell %d must use CopyStatus", x) | |||
| 909 | + | } | |||
| 910 | + | for x := 12; x < len(status); x++ { | |||
| 911 | + | require.Truef(t, status[x].Style.Equal(styles.SurfaceBar), "surrounding status cell %d must use SurfaceBar", x) | |||
| 912 | + | } | |||
| 913 | + | require.Contains(t, rowText(status), tt.want) | |||
| 914 | + | }) | |||
| 896 | } | 915 | } | ||
| 897 | - | require.Contains(t, rowText(frame.Row(frame.Height-1)), "[SCROLL]") | |||
| 898 | } | 916 | } | ||
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -774,7 +774,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin | |||
| 774 | 774 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |
| 775 | 775 | cell := inner.At(x, y) | |
| 776 | 776 | require.Equal(t, tt.wantSep, cell.Rune) | |
| 777 | - | require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme))) | |
| 777 | + | require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator must use the cached PickerSeparator role") | |
| 778 | 778 | } | |
| 779 | 779 | } | |
| 780 | 780 | }) | |
| @@ -774,7 +774,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin | |||||
| 774 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | 774 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | ||
| 775 | cell := inner.At(x, y) | 775 | cell := inner.At(x, y) | ||
| 776 | require.Equal(t, tt.wantSep, cell.Rune) | 776 | require.Equal(t, tt.wantSep, cell.Rune) | ||
| 777 | - | require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme))) | 777 | + | require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator must use the cached PickerSeparator role") |
| 778 | } | 778 | } | ||
| 779 | } | 779 | } | ||
| 780 | }) | 780 | }) | ||
M internal/usecase/daemon/render_pipeline.go +2 -4
| @@ -171,10 +171,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | 173 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | |
| 174 | - | style := neutralBorder | |
| 175 | - | if focused { | |
| 176 | - | style = styles.StatusBar | |
| 177 | - | } else { | |
| 174 | + | style := styles.StatusBar | |
| 175 | + | if !focused { | |
| 178 | 176 | style = dimmer.Dim(neutralBorder) | |
| 179 | 177 | } | |
| 180 | 178 | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |
| @@ -171,10 +171,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| 173 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | 173 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | ||
| 174 | - | style := neutralBorder | 174 | + | style := styles.StatusBar |
| 175 | - | if focused { | 175 | + | if !focused { |
| 176 | - | style = styles.StatusBar | |||
| 177 | - | } else { | |||
| 178 | style = dimmer.Dim(neutralBorder) | 176 | style = dimmer.Dim(neutralBorder) | ||
| 179 | } | 177 | } | ||
| 180 | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | 178 | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | ||
M internal/usecase/theme/resolved_styles.go +1 -0
| @@ -100,6 +100,7 @@ func indexedStyles(styles Styles, slot uint8) Styles { | |||
| 100 | 100 | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | |
| 101 | 101 | style.HasForegroundRGB = false | |
| 102 | 102 | style.Foreground = int(slot) | |
| 103 | + | style.Inverse = false | |
| 103 | 104 | return style | |
| 104 | 105 | } | |
| 105 | 106 | ||
| @@ -100,6 +100,7 @@ func indexedStyles(styles Styles, slot uint8) Styles { | |||||
| 100 | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | 100 | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | ||
| 101 | style.HasForegroundRGB = false | 101 | style.HasForegroundRGB = false | ||
| 102 | style.Foreground = int(slot) | 102 | style.Foreground = int(slot) | ||
| 103 | + | style.Inverse = false | |||
| 103 | return style | 104 | return style | ||
| 104 | } | 105 | } | ||
| 105 | 106 | ||||
M internal/usecase/theme/styles_test.go +15 -0
| @@ -139,6 +139,21 @@ func TestPulseColor(t *testing.T) { | |||
| 139 | 139 | } | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | func TestIndexedForegroundClearsInverseForNonTrueColorFallback(t *testing.T) { | |
| 143 | + | fallback := indexedForeground(renderer.Style{ | |
| 144 | + | Bold: true, | |
| 145 | + | Inverse: true, | |
| 146 | + | Foreground: -1, | |
| 147 | + | HasForegroundRGB: true, | |
| 148 | + | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 149 | + | }, 2) | |
| 150 | + | ||
| 151 | + | require.False(t, fallback.Inverse, "an indexed foreground must not become an effective background") | |
| 152 | + | require.False(t, fallback.HasForegroundRGB) | |
| 153 | + | require.Equal(t, 2, fallback.Foreground) | |
| 154 | + | require.True(t, fallback.Bold) | |
| 155 | + | } | |
| 156 | + | ||
| 142 | 157 | func TestMRUFade(t *testing.T) { | |
| 143 | 158 | base := renderer.Style{ | |
| 144 | 159 | Bold: true, | |
| @@ -139,6 +139,21 @@ func TestPulseColor(t *testing.T) { | |||||
| 139 | } | 139 | } | ||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | + | func TestIndexedForegroundClearsInverseForNonTrueColorFallback(t *testing.T) { | |||
| 143 | + | fallback := indexedForeground(renderer.Style{ | |||
| 144 | + | Bold: true, | |||
| 145 | + | Inverse: true, | |||
| 146 | + | Foreground: -1, | |||
| 147 | + | HasForegroundRGB: true, | |||
| 148 | + | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 149 | + | }, 2) | |||
| 150 | + | ||||
| 151 | + | require.False(t, fallback.Inverse, "an indexed foreground must not become an effective background") | |||
| 152 | + | require.False(t, fallback.HasForegroundRGB) | |||
| 153 | + | require.Equal(t, 2, fallback.Foreground) | |||
| 154 | + | require.True(t, fallback.Bold) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 142 | func TestMRUFade(t *testing.T) { | 157 | func TestMRUFade(t *testing.T) { | ||
| 143 | base := renderer.Style{ | 158 | base := renderer.Style{ | ||
| 144 | Bold: true, | 159 | Bold: true, | ||
M internal/usecase/visualsearch/model_test.go +17 -7
| @@ -187,13 +187,23 @@ func rowText(row []renderer.Cell) string { | |||
| 187 | 187 | return b.String() | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | - | func TestVisualSearchRenderStyledFillsBaseAndSelection(t *testing.T) { | |
| 191 | - | m := New(testSnapshot("alpha")) | |
| 192 | - | m.Insert('a') | |
| 190 | + | func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) { | |
| 193 | 191 | base := renderer.Style{Foreground: 1, Background: 2} | |
| 194 | 192 | selection := renderer.Style{Foreground: 3, Background: 4} | |
| 195 | - | frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection}) | |
| 196 | - | ||
| 197 | - | require.True(t, frame.At(19, 3).Style.Equal(base), "blank filler keeps base surface") | |
| 198 | - | require.True(t, frame.At(2, 0).Style.Equal(selection), "input caret keeps selection surface") | |
| 193 | + | for _, tt := range []struct { | |
| 194 | + | name string | |
| 195 | + | x, y int | |
| 196 | + | want renderer.Style | |
| 197 | + | why string | |
| 198 | + | }{ | |
| 199 | + | {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"}, | |
| 200 | + | {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"}, | |
| 201 | + | } { | |
| 202 | + | t.Run(tt.name, func(t *testing.T) { | |
| 203 | + | m := New(testSnapshot("alpha")) | |
| 204 | + | m.Insert('a') | |
| 205 | + | frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection}) | |
| 206 | + | require.True(t, frame.At(tt.x, tt.y).Style.Equal(tt.want), tt.why) | |
| 207 | + | }) | |
| 208 | + | } | |
| 199 | 209 | } | |
| @@ -187,13 +187,23 @@ func rowText(row []renderer.Cell) string { | |||||
| 187 | return b.String() | 187 | return b.String() | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | - | func TestVisualSearchRenderStyledFillsBaseAndSelection(t *testing.T) { | 190 | + | func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) { |
| 191 | - | m := New(testSnapshot("alpha")) | |||
| 192 | - | m.Insert('a') | |||
| 193 | base := renderer.Style{Foreground: 1, Background: 2} | 191 | base := renderer.Style{Foreground: 1, Background: 2} | ||
| 194 | selection := renderer.Style{Foreground: 3, Background: 4} | 192 | selection := renderer.Style{Foreground: 3, Background: 4} | ||
| 195 | - | frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection}) | 193 | + | for _, tt := range []struct { |
| 196 | - | 194 | + | name string | |
| 197 | - | require.True(t, frame.At(19, 3).Style.Equal(base), "blank filler keeps base surface") | 195 | + | x, y int |
| 198 | - | require.True(t, frame.At(2, 0).Style.Equal(selection), "input caret keeps selection surface") | 196 | + | want renderer.Style |
| 197 | + | why string | |||
| 198 | + | }{ | |||
| 199 | + | {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"}, | |||
| 200 | + | {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"}, | |||
| 201 | + | } { | |||
| 202 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 203 | + | m := New(testSnapshot("alpha")) | |||
| 204 | + | m.Insert('a') | |||
| 205 | + | frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection}) | |||
| 206 | + | require.True(t, frame.At(tt.x, tt.y).Style.Equal(tt.want), tt.why) | |||
| 207 | + | }) | |||
| 208 | + | } | |||
| 199 | } | 209 | } | ||
M transport_replay_integration_test.go +1 -0
| @@ -113,6 +113,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) { | |||
| 113 | 113 | frame, err := receiver.Recv() | |
| 114 | 114 | require.NoError(t, err) | |
| 115 | 115 | require.Equal(t, ports.MsgTheme, frame.Type) | |
| 116 | + | require.Equal(t, ports.MarshalTheme(want), frame.Payload, "theme payload must be preserved byte-for-byte before decode") | |
| 116 | 117 | got, err := ports.UnmarshalTheme(frame.Payload) | |
| 117 | 118 | require.NoError(t, err) | |
| 118 | 119 | require.Equal(t, want, got) | |
| @@ -113,6 +113,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) { | |||||
| 113 | frame, err := receiver.Recv() | 113 | frame, err := receiver.Recv() | ||
| 114 | require.NoError(t, err) | 114 | require.NoError(t, err) | ||
| 115 | require.Equal(t, ports.MsgTheme, frame.Type) | 115 | require.Equal(t, ports.MsgTheme, frame.Type) | ||
| 116 | + | require.Equal(t, ports.MarshalTheme(want), frame.Payload, "theme payload must be preserved byte-for-byte before decode") | |||
| 116 | got, err := ports.UnmarshalTheme(frame.Payload) | 117 | got, err := ports.UnmarshalTheme(frame.Payload) | ||
| 117 | require.NoError(t, err) | 118 | require.NoError(t, err) | ||
| 118 | require.Equal(t, want, got) | 119 | require.Equal(t, want, got) | ||