fix(theme): preserve neutral chrome surfaces
- commit
- db2c4474c0c9c326857c142ca1c3ecc2126277b5
- author
- brice · · 2026-07-18 09:12 UTC
- parent
- adf123a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +84 -9
M internal/usecase/daemon/copymode.go +2 -2
| @@ -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.CopyStatus, styles.Selection) | |
| 419 | + | copyFrame := mode.Render(styles.SurfaceBar, 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) | |
| @@ -429,7 +429,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||
| 429 | 429 | } | |
| 430 | 430 | statusY := frame.Height - 1 | |
| 431 | 431 | for x := range frame.Row(statusY) { | |
| 432 | - | frame.Row(statusY)[x] = renderer.BlankCell() | |
| 432 | + | frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar} | |
| 433 | 433 | } | |
| 434 | 434 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | |
| 435 | 435 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| @@ -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.CopyStatus, styles.Selection) | 419 | + | copyFrame := mode.Render(styles.SurfaceBar, 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) | ||
| @@ -429,7 +429,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||||
| 429 | } | 429 | } | ||
| 430 | statusY := frame.Height - 1 | 430 | statusY := frame.Height - 1 | ||
| 431 | for x := range frame.Row(statusY) { | 431 | for x := range frame.Row(statusY) { | ||
| 432 | - | frame.Row(statusY)[x] = renderer.BlankCell() | 432 | + | frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar} |
| 433 | } | 433 | } | ||
| 434 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | 434 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | ||
| 435 | return frame, []renderer.Damage{renderer.FullRedraw()} | 435 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
M internal/usecase/daemon/copymode_test.go +16 -0
| @@ -880,3 +880,19 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 880 | 880 | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07" | |
| 881 | 881 | require.Equal(t, want, string(msg.Data)) | |
| 882 | 882 | } | |
| 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) | |
| 896 | + | } | |
| 897 | + | require.Contains(t, rowText(frame.Row(frame.Height-1)), "[SCROLL]") | |
| 898 | + | } | |
| @@ -880,3 +880,19 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||||
| 880 | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07" | 880 | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07" | ||
| 881 | require.Equal(t, want, string(msg.Data)) | 881 | require.Equal(t, want, string(msg.Data)) | ||
| 882 | } | 882 | } | ||
| 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) | |||
| 896 | + | } | |||
| 897 | + | require.Contains(t, rowText(frame.Row(frame.Height-1)), "[SCROLL]") | |||
| 898 | + | } | |||
M internal/usecase/daemon/render_pipeline.go +6 -5
| @@ -89,12 +89,13 @@ 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 | 93 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | |
| 93 | 94 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 94 | 95 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 95 | 96 | content := domain.Rect{Y: 1, Width: width, Height: rows} | |
| 96 | 97 | if state.layout.valid && state.layout.root != nil { | |
| 97 | - | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border)) | |
| 98 | + | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder)) | |
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | 101 | 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 | |
| @@ -107,7 +108,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 107 | 108 | for _, pane := range state.panes { | |
| 108 | 109 | pl := offsetPlacement(pane.placement, 0, 1) | |
| 109 | 110 | if pl.TitleBar.Height > 0 { | |
| 110 | - | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer) | |
| 111 | + | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer) | |
| 111 | 112 | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | |
| 112 | 113 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height}) | |
| 113 | 114 | } | |
| @@ -168,12 +169,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 168 | 169 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 169 | 170 | } | |
| 170 | 171 | ||
| 171 | - | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) { | |
| 172 | - | style := styles.Border | |
| 172 | + | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | |
| 173 | + | style := neutralBorder | |
| 173 | 174 | if focused { | |
| 174 | 175 | style = styles.StatusBar | |
| 175 | 176 | } else { | |
| 176 | - | style = dimmer.Dim(style) | |
| 177 | + | style = dimmer.Dim(neutralBorder) | |
| 177 | 178 | } | |
| 178 | 179 | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |
| 179 | 180 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |
| @@ -89,12 +89,13 @@ 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 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | 93 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | ||
| 93 | 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) | ||
| 94 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | 95 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | ||
| 95 | content := domain.Rect{Y: 1, Width: width, Height: rows} | 96 | content := domain.Rect{Y: 1, Width: width, Height: rows} | ||
| 96 | if state.layout.valid && state.layout.root != nil { | 97 | if state.layout.valid && state.layout.root != nil { | ||
| 97 | - | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border)) | 98 | + | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder)) |
| 98 | } | 99 | } | ||
| 99 | 100 | ||||
| 100 | 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 | 101 | 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 | ||
| @@ -107,7 +108,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 107 | for _, pane := range state.panes { | 108 | for _, pane := range state.panes { | ||
| 108 | pl := offsetPlacement(pane.placement, 0, 1) | 109 | pl := offsetPlacement(pane.placement, 0, 1) | ||
| 109 | if pl.TitleBar.Height > 0 { | 110 | if pl.TitleBar.Height > 0 { | ||
| 110 | - | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer) | 111 | + | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer) |
| 111 | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | 112 | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | ||
| 112 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height}) | 113 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height}) | ||
| 113 | } | 114 | } | ||
| @@ -168,12 +169,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 168 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 169 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 169 | } | 170 | } | ||
| 170 | 171 | ||||
| 171 | - | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) { | 172 | + | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { |
| 172 | - | style := styles.Border | 173 | + | style := neutralBorder |
| 173 | if focused { | 174 | if focused { | ||
| 174 | style = styles.StatusBar | 175 | style = styles.StatusBar | ||
| 175 | } else { | 176 | } else { | ||
| 176 | - | style = dimmer.Dim(style) | 177 | + | style = dimmer.Dim(neutralBorder) |
| 177 | } | 178 | } | ||
| 178 | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | 179 | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | ||
| 179 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | 180 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | ||
M internal/usecase/daemon/theme_cache_test.go +26 -0
| @@ -76,3 +76,29 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) { | |||
| 76 | 76 | require.Equal(t, cell, out.frame.At(0, 1)) | |
| 77 | 77 | require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style) | |
| 78 | 78 | } | |
| 79 | + | ||
| 80 | + | func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |
| 81 | + | raw := cacheTheme() | |
| 82 | + | resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 83 | + | neutral := renderer.DefaultStyle() | |
| 84 | + | neutral.HasForegroundRGB = true | |
| 85 | + | neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40) | |
| 86 | + | want := themeui.NewDimmer(raw).Dim(neutral) | |
| 87 | + | accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted) | |
| 88 | + | ||
| 89 | + | split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw) | |
| 90 | + | split.styles = resolved.Styles | |
| 91 | + | splitOut := composeFrame(split, composeCacheInput{}) | |
| 92 | + | require.Equal(t, 'L', splitOut.frame.At(0, 1).Rune, "split geometry and pane content must remain unchanged") | |
| 93 | + | require.Equal(t, 'R', splitOut.frame.At(21, 1).Rune, "split geometry and pane content must remain unchanged") | |
| 94 | + | require.Equal(t, want, splitOut.frame.At(20, 1).Style, "divider must dim a neutral input") | |
| 95 | + | require.NotEqual(t, accentDerived, splitOut.frame.At(20, 1).Style, "divider must exclude accent-ramp styles from Dimmer") | |
| 96 | + | ||
| 97 | + | titles := cachedStackTitleState("inactive", 1, true) | |
| 98 | + | titles.theme, titles.styles = raw, resolved.Styles | |
| 99 | + | titleOut := composeFrame(titles, composeCacheInput{}) | |
| 100 | + | require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged") | |
| 101 | + | require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input") | |
| 102 | + | require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer") | |
| 103 | + | require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged") | |
| 104 | + | } | |
| @@ -76,3 +76,29 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) { | |||||
| 76 | require.Equal(t, cell, out.frame.At(0, 1)) | 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) | 77 | require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style) | ||
| 78 | } | 78 | } | ||
| 79 | + | ||||
| 80 | + | func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |||
| 81 | + | raw := cacheTheme() | |||
| 82 | + | resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |||
| 83 | + | neutral := renderer.DefaultStyle() | |||
| 84 | + | neutral.HasForegroundRGB = true | |||
| 85 | + | neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40) | |||
| 86 | + | want := themeui.NewDimmer(raw).Dim(neutral) | |||
| 87 | + | accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted) | |||
| 88 | + | ||||
| 89 | + | split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw) | |||
| 90 | + | split.styles = resolved.Styles | |||
| 91 | + | splitOut := composeFrame(split, composeCacheInput{}) | |||
| 92 | + | require.Equal(t, 'L', splitOut.frame.At(0, 1).Rune, "split geometry and pane content must remain unchanged") | |||
| 93 | + | require.Equal(t, 'R', splitOut.frame.At(21, 1).Rune, "split geometry and pane content must remain unchanged") | |||
| 94 | + | require.Equal(t, want, splitOut.frame.At(20, 1).Style, "divider must dim a neutral input") | |||
| 95 | + | require.NotEqual(t, accentDerived, splitOut.frame.At(20, 1).Style, "divider must exclude accent-ramp styles from Dimmer") | |||
| 96 | + | ||||
| 97 | + | titles := cachedStackTitleState("inactive", 1, true) | |||
| 98 | + | titles.theme, titles.styles = raw, resolved.Styles | |||
| 99 | + | titleOut := composeFrame(titles, composeCacheInput{}) | |||
| 100 | + | require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged") | |||
| 101 | + | require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input") | |||
| 102 | + | require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer") | |||
| 103 | + | require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged") | |||
| 104 | + | } | |||
M internal/usecase/picker/model_test.go +27 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -348,3 +349,29 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |||
| 348 | 349 | require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface") | |
| 349 | 350 | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | |
| 350 | 351 | } | |
| 352 | + | ||
| 353 | + | func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) { | |
| 354 | + | palette := [16]renderer.RGB{} | |
| 355 | + | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 356 | + | palette[10] = palette[2] | |
| 357 | + | hostTheme := themeui.Theme{ | |
| 358 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 359 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 360 | + | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |
| 361 | + | } | |
| 362 | + | styles := themeui.Resolve(hostTheme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | |
| 363 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB, "description must retain the inactive row surface") | |
| 364 | + | ||
| 365 | + | model := New([]SessionView{ | |
| 366 | + | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |
| 367 | + | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 368 | + | }, "selected", 0) | |
| 369 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{ | |
| 370 | + | Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive, | |
| 371 | + | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | |
| 372 | + | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | |
| 373 | + | Separator: styles.PickerSeparator, | |
| 374 | + | }) | |
| 375 | + | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background") | |
| 376 | + | require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface") | |
| 377 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 7 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 8 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| @@ -348,3 +349,29 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |||||
| 348 | require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface") | 349 | require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface") | ||
| 349 | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | 350 | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | ||
| 350 | } | 351 | } | ||
| 352 | + | ||||
| 353 | + | func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) { | |||
| 354 | + | palette := [16]renderer.RGB{} | |||
| 355 | + | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | |||
| 356 | + | palette[10] = palette[2] | |||
| 357 | + | hostTheme := themeui.Theme{ | |||
| 358 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |||
| 359 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 360 | + | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |||
| 361 | + | } | |||
| 362 | + | styles := themeui.Resolve(hostTheme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | |||
| 363 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB, "description must retain the inactive row surface") | |||
| 364 | + | ||||
| 365 | + | model := New([]SessionView{ | |||
| 366 | + | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |||
| 367 | + | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |||
| 368 | + | }, "selected", 0) | |||
| 369 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{ | |||
| 370 | + | Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive, | |||
| 371 | + | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | |||
| 372 | + | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | |||
| 373 | + | Separator: styles.PickerSeparator, | |||
| 374 | + | }) | |||
| 375 | + | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background") | |||
| 376 | + | require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface") | |||
| 377 | + | } | |||
M internal/usecase/theme/resolved_styles.go +7 -2
| @@ -36,6 +36,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 36 | 36 | ||
| 37 | 37 | inactiveTitle := secondarySurface(ramp.SurfaceInactive) | |
| 38 | 38 | activeTitle := secondarySurface(ramp.SurfaceActive) | |
| 39 | + | inactiveDescription := secondarySurface(ramp.SurfaceInactive) | |
| 39 | 40 | barDescription := secondarySurface(ramp.SurfaceBar) | |
| 40 | 41 | styles := Styles{ | |
| 41 | 42 | SurfaceBar: ramp.SurfaceBar, | |
| @@ -53,7 +54,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 53 | 54 | ||
| 54 | 55 | PickerBase: ramp.SurfaceBar, | |
| 55 | 56 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 56 | - | PickerDescription: barDescription, | |
| 57 | + | PickerDescription: inactiveDescription, | |
| 57 | 58 | // Separators are secondary text on the picker base surface, not | |
| 58 | 59 | // non-text borders; they therefore require the normal 4.5:1 contrast. | |
| 59 | 60 | PickerSeparator: barDescription, | |
| @@ -152,13 +153,17 @@ func withMRUStyles(styles Styles, ramp Ramp) Styles { | |||
| 152 | 153 | return styles | |
| 153 | 154 | } | |
| 154 | 155 | ||
| 155 | - | func neutralBorderStyle(t Theme) renderer.Style { | |
| 156 | + | // NeutralBorderStyle is the existing non-accent structural border input for | |
| 157 | + | // dimmed pane chrome. It intentionally does not consume a resolved accent. | |
| 158 | + | func NeutralBorderStyle(t Theme) renderer.Style { | |
| 156 | 159 | if !usable(t) { | |
| 157 | 160 | return renderer.DefaultStyle() | |
| 158 | 161 | } | |
| 159 | 162 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |
| 160 | 163 | } | |
| 161 | 164 | ||
| 165 | + | func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) } | |
| 166 | + | ||
| 162 | 167 | func withLegacyAliases(styles Styles, t Theme) Styles { | |
| 163 | 168 | styles.StatusBar = styles.SurfaceBar | |
| 164 | 169 | styles.Accent = styles.SurfaceActive | |
| @@ -36,6 +36,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 36 | 36 | ||||
| 37 | inactiveTitle := secondarySurface(ramp.SurfaceInactive) | 37 | inactiveTitle := secondarySurface(ramp.SurfaceInactive) | ||
| 38 | activeTitle := secondarySurface(ramp.SurfaceActive) | 38 | activeTitle := secondarySurface(ramp.SurfaceActive) | ||
| 39 | + | inactiveDescription := secondarySurface(ramp.SurfaceInactive) | |||
| 39 | barDescription := secondarySurface(ramp.SurfaceBar) | 40 | barDescription := secondarySurface(ramp.SurfaceBar) | ||
| 40 | styles := Styles{ | 41 | styles := Styles{ | ||
| 41 | SurfaceBar: ramp.SurfaceBar, | 42 | SurfaceBar: ramp.SurfaceBar, | ||
| @@ -53,7 +54,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 53 | 54 | ||||
| 54 | PickerBase: ramp.SurfaceBar, | 55 | PickerBase: ramp.SurfaceBar, | ||
| 55 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | 56 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | ||
| 56 | - | PickerDescription: barDescription, | 57 | + | PickerDescription: inactiveDescription, |
| 57 | // Separators are secondary text on the picker base surface, not | 58 | // Separators are secondary text on the picker base surface, not | ||
| 58 | // non-text borders; they therefore require the normal 4.5:1 contrast. | 59 | // non-text borders; they therefore require the normal 4.5:1 contrast. | ||
| 59 | PickerSeparator: barDescription, | 60 | PickerSeparator: barDescription, | ||
| @@ -152,13 +153,17 @@ func withMRUStyles(styles Styles, ramp Ramp) Styles { | |||||
| 152 | return styles | 153 | return styles | ||
| 153 | } | 154 | } | ||
| 154 | 155 | ||||
| 155 | - | func neutralBorderStyle(t Theme) renderer.Style { | 156 | + | // NeutralBorderStyle is the existing non-accent structural border input for |
| 157 | + | // dimmed pane chrome. It intentionally does not consume a resolved accent. | |||
| 158 | + | func NeutralBorderStyle(t Theme) renderer.Style { | |||
| 156 | if !usable(t) { | 159 | if !usable(t) { | ||
| 157 | return renderer.DefaultStyle() | 160 | return renderer.DefaultStyle() | ||
| 158 | } | 161 | } | ||
| 159 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | 162 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | ||
| 160 | } | 163 | } | ||
| 161 | 164 | ||||
| 165 | + | func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) } | |||
| 166 | + | ||||
| 162 | func withLegacyAliases(styles Styles, t Theme) Styles { | 167 | func withLegacyAliases(styles Styles, t Theme) Styles { | ||
| 163 | styles.StatusBar = styles.SurfaceBar | 168 | styles.StatusBar = styles.SurfaceBar | ||
| 164 | styles.Accent = styles.SurfaceActive | 169 | styles.Accent = styles.SurfaceActive | ||