fix(theme): retain fallback chrome surfaces
- commit
- 256407fcdb4090cc15313458b5111846d3ececae
- author
- brice · · 2026-07-18 09:32 UTC
- parent
- db2c447
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +254 -29
M internal/usecase/daemon/palette_test.go +68 -0
| @@ -13,10 +13,78 @@ import ( | |||
| 13 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 14 | "github.com/bnema/vev/internal/usecase/command" | |
| 15 | 15 | "github.com/bnema/vev/internal/usecase/palette" | |
| 16 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | 18 | "github.com/bnema/vev/pkg/renderer" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 21 | + | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | |
| 22 | + | paletteColors := [16]renderer.RGB{} | |
| 23 | + | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 24 | + | paletteColors[10] = paletteColors[2] | |
| 25 | + | accentTheme := themeui.Theme{ | |
| 26 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 27 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 28 | + | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |
| 29 | + | } | |
| 30 | + | indexedTheme := accentTheme | |
| 31 | + | indexedTheme.TrueColor = false | |
| 32 | + | neutralTheme := accentTheme | |
| 33 | + | neutralTheme.PaletteKnown = 0 | |
| 34 | + | neutralTheme.SchemeKnown = false | |
| 35 | + | ||
| 36 | + | defaults := domain.Defaults() | |
| 37 | + | paletteOff := defaults | |
| 38 | + | paletteOff.ThemePalette = false | |
| 39 | + | forcedDark := defaults | |
| 40 | + | forcedDark.Theme = domain.ThemeDark | |
| 41 | + | forcedLight := defaults | |
| 42 | + | forcedLight.Theme = domain.ThemeLight | |
| 43 | + | tests := []struct { | |
| 44 | + | name string | |
| 45 | + | raw themeui.Theme | |
| 46 | + | config domain.Config | |
| 47 | + | indexed bool | |
| 48 | + | }{ | |
| 49 | + | {name: "truecolor accent", raw: accentTheme, config: defaults}, | |
| 50 | + | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, | |
| 51 | + | {name: "palette off", raw: accentTheme, config: paletteOff}, | |
| 52 | + | {name: "forced dark", raw: accentTheme, config: forcedDark}, | |
| 53 | + | {name: "forced light", raw: accentTheme, config: forcedLight}, | |
| 54 | + | {name: "neutral fallback", raw: neutralTheme, config: defaults}, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | for _, tt := range tests { | |
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |
| 59 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 60 | + | d.ApplyConfig(tt.config) | |
| 61 | + | applied := d.resolveAppliedTheme(tt.raw) | |
| 62 | + | model := palette.New(palette.CommandResults([]command.Command{ | |
| 63 | + | {Code: "ONE", Name: "One", Desc: "first description"}, | |
| 64 | + | {Code: "TWO", Name: "Two", Desc: "second description"}, | |
| 65 | + | })) | |
| 66 | + | model.Down() | |
| 67 | + | state := capturedRenderState{ | |
| 68 | + | theme: applied.Raw, | |
| 69 | + | styles: applied.Resolved.Styles, | |
| 70 | + | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |
| 71 | + | } | |
| 72 | + | snap := &overlayRenderSnapshot{paletteActive: true, paletteModel: model} | |
| 73 | + | ||
| 74 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 75 | + | ||
| 76 | + | description := state.overlays.palette.inner.At(4, 1).Style | |
| 77 | + | require.Equal(t, state.styles.SurfaceInactive.Background, description.Background) | |
| 78 | + | require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB) | |
| 79 | + | require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB) | |
| 80 | + | if tt.indexed { | |
| 81 | + | require.Equal(t, 2, description.Foreground) | |
| 82 | + | require.False(t, description.HasBackgroundRGB) | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 20 | 88 | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |
| 21 | 89 | p1, release1 := newBlockingPTY(t) | |
| 22 | 90 | p2, release2 := newBlockingPTY(t) | |
| @@ -13,10 +13,78 @@ import ( | |||||
| 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 14 | "github.com/bnema/vev/internal/usecase/command" | 14 | "github.com/bnema/vev/internal/usecase/command" | ||
| 15 | "github.com/bnema/vev/internal/usecase/palette" | 15 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 16 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 16 | "github.com/bnema/vev/internal/usecase/ui" | 17 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 17 | "github.com/bnema/vev/pkg/renderer" | 18 | "github.com/bnema/vev/pkg/renderer" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 21 | + | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | |||
| 22 | + | paletteColors := [16]renderer.RGB{} | |||
| 23 | + | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |||
| 24 | + | paletteColors[10] = paletteColors[2] | |||
| 25 | + | accentTheme := themeui.Theme{ | |||
| 26 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |||
| 27 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 28 | + | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |||
| 29 | + | } | |||
| 30 | + | indexedTheme := accentTheme | |||
| 31 | + | indexedTheme.TrueColor = false | |||
| 32 | + | neutralTheme := accentTheme | |||
| 33 | + | neutralTheme.PaletteKnown = 0 | |||
| 34 | + | neutralTheme.SchemeKnown = false | |||
| 35 | + | ||||
| 36 | + | defaults := domain.Defaults() | |||
| 37 | + | paletteOff := defaults | |||
| 38 | + | paletteOff.ThemePalette = false | |||
| 39 | + | forcedDark := defaults | |||
| 40 | + | forcedDark.Theme = domain.ThemeDark | |||
| 41 | + | forcedLight := defaults | |||
| 42 | + | forcedLight.Theme = domain.ThemeLight | |||
| 43 | + | tests := []struct { | |||
| 44 | + | name string | |||
| 45 | + | raw themeui.Theme | |||
| 46 | + | config domain.Config | |||
| 47 | + | indexed bool | |||
| 48 | + | }{ | |||
| 49 | + | {name: "truecolor accent", raw: accentTheme, config: defaults}, | |||
| 50 | + | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, | |||
| 51 | + | {name: "palette off", raw: accentTheme, config: paletteOff}, | |||
| 52 | + | {name: "forced dark", raw: accentTheme, config: forcedDark}, | |||
| 53 | + | {name: "forced light", raw: accentTheme, config: forcedLight}, | |||
| 54 | + | {name: "neutral fallback", raw: neutralTheme, config: defaults}, | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | for _, tt := range tests { | |||
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 59 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 60 | + | d.ApplyConfig(tt.config) | |||
| 61 | + | applied := d.resolveAppliedTheme(tt.raw) | |||
| 62 | + | model := palette.New(palette.CommandResults([]command.Command{ | |||
| 63 | + | {Code: "ONE", Name: "One", Desc: "first description"}, | |||
| 64 | + | {Code: "TWO", Name: "Two", Desc: "second description"}, | |||
| 65 | + | })) | |||
| 66 | + | model.Down() | |||
| 67 | + | state := capturedRenderState{ | |||
| 68 | + | theme: applied.Raw, | |||
| 69 | + | styles: applied.Resolved.Styles, | |||
| 70 | + | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |||
| 71 | + | } | |||
| 72 | + | snap := &overlayRenderSnapshot{paletteActive: true, paletteModel: model} | |||
| 73 | + | ||||
| 74 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |||
| 75 | + | ||||
| 76 | + | description := state.overlays.palette.inner.At(4, 1).Style | |||
| 77 | + | require.Equal(t, state.styles.SurfaceInactive.Background, description.Background) | |||
| 78 | + | require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB) | |||
| 79 | + | require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB) | |||
| 80 | + | if tt.indexed { | |||
| 81 | + | require.Equal(t, 2, description.Foreground) | |||
| 82 | + | require.False(t, description.HasBackgroundRGB) | |||
| 83 | + | } | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 20 | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | 88 | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | ||
| 21 | p1, release1 := newBlockingPTY(t) | 89 | p1, release1 := newBlockingPTY(t) | ||
| 22 | p2, release2 := newBlockingPTY(t) | 90 | p2, release2 := newBlockingPTY(t) | ||
M internal/usecase/daemon/picker_test.go +60 -12
| @@ -642,36 +642,84 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||
| 642 | 642 | require.True(t, subscribed) | |
| 643 | 643 | } | |
| 644 | 644 | ||
| 645 | - | func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) { | |
| 646 | - | for _, tt := range []struct { | |
| 647 | - | name string | |
| 648 | - | theme themeui.Theme | |
| 645 | + | func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) { | |
| 646 | + | palette := [16]renderer.RGB{} | |
| 647 | + | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 648 | + | palette[10] = palette[2] | |
| 649 | + | accentTheme := themeui.Theme{ | |
| 650 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 651 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 652 | + | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |
| 653 | + | } | |
| 654 | + | indexedTheme := accentTheme | |
| 655 | + | indexedTheme.TrueColor = false | |
| 656 | + | neutralTheme := accentTheme | |
| 657 | + | neutralTheme.PaletteKnown = 0 | |
| 658 | + | neutralTheme.SchemeKnown = false | |
| 659 | + | ||
| 660 | + | defaults := domain.Defaults() | |
| 661 | + | paletteOff := defaults | |
| 662 | + | paletteOff.ThemePalette = false | |
| 663 | + | forcedDark := defaults | |
| 664 | + | forcedDark.Theme = domain.ThemeDark | |
| 665 | + | forcedLight := defaults | |
| 666 | + | forcedLight.Theme = domain.ThemeLight | |
| 667 | + | tests := []struct { | |
| 668 | + | name string | |
| 669 | + | raw themeui.Theme | |
| 670 | + | config domain.Config | |
| 671 | + | indexed bool | |
| 649 | 672 | }{ | |
| 650 | - | {name: "default terminal", theme: themeui.Theme{}}, | |
| 651 | - | {name: "truecolor terminal", theme: themeui.BuiltinDark}, | |
| 652 | - | } { | |
| 673 | + | {name: "truecolor accent", raw: accentTheme, config: defaults}, | |
| 674 | + | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, | |
| 675 | + | {name: "palette off", raw: accentTheme, config: paletteOff}, | |
| 676 | + | {name: "forced dark", raw: accentTheme, config: forcedDark}, | |
| 677 | + | {name: "forced light", raw: accentTheme, config: forcedLight}, | |
| 678 | + | {name: "neutral fallback", raw: neutralTheme, config: defaults}, | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | for _, tt := range tests { | |
| 653 | 682 | t.Run(tt.name, func(t *testing.T) { | |
| 683 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 684 | + | d.ApplyConfig(tt.config) | |
| 685 | + | applied := d.resolveAppliedTheme(tt.raw) | |
| 654 | 686 | state := capturedRenderState{ | |
| 655 | - | theme: tt.theme, | |
| 656 | - | styles: themeui.Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | |
| 687 | + | theme: applied.Raw, | |
| 688 | + | styles: applied.Resolved.Styles, | |
| 657 | 689 | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |
| 658 | 690 | } | |
| 659 | 691 | snap := &overlayRenderSnapshot{ | |
| 660 | 692 | pickerActive: true, | |
| 661 | - | pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0), | |
| 693 | + | pickerModel: picker.New([]picker.SessionView{ | |
| 694 | + | {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0}, | |
| 695 | + | {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 696 | + | }, "selected", 0), | |
| 662 | 697 | } | |
| 663 | 698 | ||
| 664 | 699 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 665 | 700 | ||
| 701 | + | require.Equal(t, state.styles.SurfaceInactive.Background, state.styles.PickerDescription.Background) | |
| 702 | + | require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, state.styles.PickerDescription.HasBackgroundRGB) | |
| 703 | + | require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, state.styles.PickerDescription.BackgroundRGB) | |
| 704 | + | require.Equal(t, state.styles.SurfaceBar.Background, state.styles.PickerSeparator.Background) | |
| 705 | + | require.Equal(t, state.styles.SurfaceBar.HasBackgroundRGB, state.styles.PickerSeparator.HasBackgroundRGB) | |
| 706 | + | require.Equal(t, state.styles.SurfaceBar.BackgroundRGB, state.styles.PickerSeparator.BackgroundRGB) | |
| 707 | + | if tt.indexed { | |
| 708 | + | require.Equal(t, 2, state.styles.PickerDescription.Foreground) | |
| 709 | + | require.Equal(t, 2, state.styles.PickerSeparator.Foreground) | |
| 710 | + | require.False(t, state.styles.PickerDescription.HasBackgroundRGB) | |
| 711 | + | require.False(t, state.styles.PickerSeparator.HasBackgroundRGB) | |
| 712 | + | } | |
| 713 | + | ||
| 666 | 714 | inner := state.overlays.picker.inner | |
| 667 | 715 | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 668 | 716 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | |
| 669 | - | want := state.styles.PickerSeparator | |
| 717 | + | require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text retains the inactive row surface") | |
| 670 | 718 | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | |
| 671 | 719 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |
| 672 | 720 | cell := inner.At(x, y) | |
| 673 | 721 | require.Equal(t, '│', cell.Rune) | |
| 674 | - | require.True(t, cell.Style.Equal(want)) | |
| 722 | + | require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator retains the bar surface") | |
| 675 | 723 | } | |
| 676 | 724 | } | |
| 677 | 725 | }) | |
| @@ -642,36 +642,84 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||||
| 642 | require.True(t, subscribed) | 642 | require.True(t, subscribed) | ||
| 643 | } | 643 | } | ||
| 644 | 644 | ||||
| 645 | - | func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) { | 645 | + | func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) { |
| 646 | - | for _, tt := range []struct { | 646 | + | palette := [16]renderer.RGB{} |
| 647 | - | name string | 647 | + | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} |
| 648 | - | theme themeui.Theme | 648 | + | palette[10] = palette[2] |
| 649 | + | accentTheme := themeui.Theme{ | |||
| 650 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |||
| 651 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 652 | + | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |||
| 653 | + | } | |||
| 654 | + | indexedTheme := accentTheme | |||
| 655 | + | indexedTheme.TrueColor = false | |||
| 656 | + | neutralTheme := accentTheme | |||
| 657 | + | neutralTheme.PaletteKnown = 0 | |||
| 658 | + | neutralTheme.SchemeKnown = false | |||
| 659 | + | ||||
| 660 | + | defaults := domain.Defaults() | |||
| 661 | + | paletteOff := defaults | |||
| 662 | + | paletteOff.ThemePalette = false | |||
| 663 | + | forcedDark := defaults | |||
| 664 | + | forcedDark.Theme = domain.ThemeDark | |||
| 665 | + | forcedLight := defaults | |||
| 666 | + | forcedLight.Theme = domain.ThemeLight | |||
| 667 | + | tests := []struct { | |||
| 668 | + | name string | |||
| 669 | + | raw themeui.Theme | |||
| 670 | + | config domain.Config | |||
| 671 | + | indexed bool | |||
| 649 | }{ | 672 | }{ | ||
| 650 | - | {name: "default terminal", theme: themeui.Theme{}}, | 673 | + | {name: "truecolor accent", raw: accentTheme, config: defaults}, |
| 651 | - | {name: "truecolor terminal", theme: themeui.BuiltinDark}, | 674 | + | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, |
| 652 | - | } { | 675 | + | {name: "palette off", raw: accentTheme, config: paletteOff}, |
| 676 | + | {name: "forced dark", raw: accentTheme, config: forcedDark}, | |||
| 677 | + | {name: "forced light", raw: accentTheme, config: forcedLight}, | |||
| 678 | + | {name: "neutral fallback", raw: neutralTheme, config: defaults}, | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | for _, tt := range tests { | |||
| 653 | t.Run(tt.name, func(t *testing.T) { | 682 | t.Run(tt.name, func(t *testing.T) { | ||
| 683 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 684 | + | d.ApplyConfig(tt.config) | |||
| 685 | + | applied := d.resolveAppliedTheme(tt.raw) | |||
| 654 | state := capturedRenderState{ | 686 | state := capturedRenderState{ | ||
| 655 | - | theme: tt.theme, | 687 | + | theme: applied.Raw, |
| 656 | - | styles: themeui.Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | 688 | + | styles: applied.Resolved.Styles, |
| 657 | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | 689 | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | ||
| 658 | } | 690 | } | ||
| 659 | snap := &overlayRenderSnapshot{ | 691 | snap := &overlayRenderSnapshot{ | ||
| 660 | pickerActive: true, | 692 | pickerActive: true, | ||
| 661 | - | pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0), | 693 | + | pickerModel: picker.New([]picker.SessionView{ |
| 694 | + | {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0}, | |||
| 695 | + | {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |||
| 696 | + | }, "selected", 0), | |||
| 662 | } | 697 | } | ||
| 663 | 698 | ||||
| 664 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | 699 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | ||
| 665 | 700 | ||||
| 701 | + | require.Equal(t, state.styles.SurfaceInactive.Background, state.styles.PickerDescription.Background) | |||
| 702 | + | require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, state.styles.PickerDescription.HasBackgroundRGB) | |||
| 703 | + | require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, state.styles.PickerDescription.BackgroundRGB) | |||
| 704 | + | require.Equal(t, state.styles.SurfaceBar.Background, state.styles.PickerSeparator.Background) | |||
| 705 | + | require.Equal(t, state.styles.SurfaceBar.HasBackgroundRGB, state.styles.PickerSeparator.HasBackgroundRGB) | |||
| 706 | + | require.Equal(t, state.styles.SurfaceBar.BackgroundRGB, state.styles.PickerSeparator.BackgroundRGB) | |||
| 707 | + | if tt.indexed { | |||
| 708 | + | require.Equal(t, 2, state.styles.PickerDescription.Foreground) | |||
| 709 | + | require.Equal(t, 2, state.styles.PickerSeparator.Foreground) | |||
| 710 | + | require.False(t, state.styles.PickerDescription.HasBackgroundRGB) | |||
| 711 | + | require.False(t, state.styles.PickerSeparator.HasBackgroundRGB) | |||
| 712 | + | } | |||
| 713 | + | ||||
| 666 | inner := state.overlays.picker.inner | 714 | inner := state.overlays.picker.inner | ||
| 667 | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | 715 | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | ||
| 668 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | 716 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | ||
| 669 | - | want := state.styles.PickerSeparator | 717 | + | require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text retains the inactive row surface") |
| 670 | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | 718 | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | ||
| 671 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | 719 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | ||
| 672 | cell := inner.At(x, y) | 720 | cell := inner.At(x, y) | ||
| 673 | require.Equal(t, '│', cell.Rune) | 721 | require.Equal(t, '│', cell.Rune) | ||
| 674 | - | require.True(t, cell.Style.Equal(want)) | 722 | + | require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator retains the bar surface") |
| 675 | } | 723 | } | ||
| 676 | } | 724 | } | ||
| 677 | }) | 725 | }) | ||
M internal/usecase/palette/model_test.go +54 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 10 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| 12 | 13 | ) | |
| @@ -270,6 +271,59 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 270 | 271 | } | |
| 271 | 272 | } | |
| 272 | 273 | ||
| 274 | + | func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) { | |
| 275 | + | paletteColors := [16]renderer.RGB{} | |
| 276 | + | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 277 | + | paletteColors[10] = paletteColors[2] | |
| 278 | + | accentTheme := themeui.Theme{ | |
| 279 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 280 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 281 | + | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |
| 282 | + | } | |
| 283 | + | indexedTheme := accentTheme | |
| 284 | + | indexedTheme.TrueColor = false | |
| 285 | + | paletteOffTheme := accentTheme | |
| 286 | + | paletteOffTheme.UsePalette = false | |
| 287 | + | neutralTheme := paletteOffTheme | |
| 288 | + | neutralTheme.PaletteKnown = 0 | |
| 289 | + | ||
| 290 | + | tests := []struct { | |
| 291 | + | name string | |
| 292 | + | theme themeui.Theme | |
| 293 | + | policy domain.ThemeAccent | |
| 294 | + | }{ | |
| 295 | + | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 296 | + | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 297 | + | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 298 | + | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 299 | + | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 300 | + | {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | for _, tt := range tests { | |
| 304 | + | t.Run(tt.name, func(t *testing.T) { | |
| 305 | + | styles := themeui.Resolve(tt.theme, tt.policy).Styles | |
| 306 | + | model := New(CommandResults([]command.Command{ | |
| 307 | + | cmd("ONE", "One", "first description"), | |
| 308 | + | cmd("TWO", "Two", "second description"), | |
| 309 | + | })) | |
| 310 | + | model.Down() | |
| 311 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{ | |
| 312 | + | Base: styles.PickerBase, Row: styles.SurfaceInactive, | |
| 313 | + | Selection: styles.PickerSelection, Description: styles.PickerDescription, | |
| 314 | + | }}) | |
| 315 | + | description := frame.At(4, 1).Style | |
| 316 | + | require.Equal(t, styles.SurfaceInactive.Background, description.Background) | |
| 317 | + | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB) | |
| 318 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB) | |
| 319 | + | if tt.name == "indexed only" { | |
| 320 | + | require.Equal(t, 2, description.Foreground) | |
| 321 | + | require.False(t, description.HasBackgroundRGB) | |
| 322 | + | } | |
| 323 | + | }) | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 273 | 327 | func TestModelCompleteSelected(t *testing.T) { | |
| 274 | 328 | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 275 | 329 | jrs.Arguments = command.ArgumentsRequired | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/usecase/command" | 9 | "github.com/bnema/vev/internal/usecase/command" | ||
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 10 | "github.com/bnema/vev/pkg/renderer" | 11 | "github.com/bnema/vev/pkg/renderer" | ||
| 11 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 12 | ) | 13 | ) | ||
| @@ -270,6 +271,59 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 270 | } | 271 | } | ||
| 271 | } | 272 | } | ||
| 272 | 273 | ||||
| 274 | + | func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) { | |||
| 275 | + | paletteColors := [16]renderer.RGB{} | |||
| 276 | + | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |||
| 277 | + | paletteColors[10] = paletteColors[2] | |||
| 278 | + | accentTheme := themeui.Theme{ | |||
| 279 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |||
| 280 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 281 | + | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |||
| 282 | + | } | |||
| 283 | + | indexedTheme := accentTheme | |||
| 284 | + | indexedTheme.TrueColor = false | |||
| 285 | + | paletteOffTheme := accentTheme | |||
| 286 | + | paletteOffTheme.UsePalette = false | |||
| 287 | + | neutralTheme := paletteOffTheme | |||
| 288 | + | neutralTheme.PaletteKnown = 0 | |||
| 289 | + | ||||
| 290 | + | tests := []struct { | |||
| 291 | + | name string | |||
| 292 | + | theme themeui.Theme | |||
| 293 | + | policy domain.ThemeAccent | |||
| 294 | + | }{ | |||
| 295 | + | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |||
| 296 | + | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |||
| 297 | + | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 298 | + | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 299 | + | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 300 | + | {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | for _, tt := range tests { | |||
| 304 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 305 | + | styles := themeui.Resolve(tt.theme, tt.policy).Styles | |||
| 306 | + | model := New(CommandResults([]command.Command{ | |||
| 307 | + | cmd("ONE", "One", "first description"), | |||
| 308 | + | cmd("TWO", "Two", "second description"), | |||
| 309 | + | })) | |||
| 310 | + | model.Down() | |||
| 311 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{ | |||
| 312 | + | Base: styles.PickerBase, Row: styles.SurfaceInactive, | |||
| 313 | + | Selection: styles.PickerSelection, Description: styles.PickerDescription, | |||
| 314 | + | }}) | |||
| 315 | + | description := frame.At(4, 1).Style | |||
| 316 | + | require.Equal(t, styles.SurfaceInactive.Background, description.Background) | |||
| 317 | + | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB) | |||
| 318 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB) | |||
| 319 | + | if tt.name == "indexed only" { | |||
| 320 | + | require.Equal(t, 2, description.Foreground) | |||
| 321 | + | require.False(t, description.HasBackgroundRGB) | |||
| 322 | + | } | |||
| 323 | + | }) | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 273 | func TestModelCompleteSelected(t *testing.T) { | 327 | func TestModelCompleteSelected(t *testing.T) { | ||
| 274 | jrs := cmd("JRS", "Jump", "Jump to recent session") | 328 | jrs := cmd("JRS", "Jump", "Jump to recent session") | ||
| 275 | jrs.Arguments = command.ArgumentsRequired | 329 | jrs.Arguments = command.ArgumentsRequired | ||
M internal/usecase/picker/model_test.go +48 -12
| @@ -350,28 +350,64 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |||
| 350 | 350 | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | |
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | - | func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) { | |
| 353 | + | func TestPickerDetailKeepsInactiveSurfaceAcrossAccentFallbacks(t *testing.T) { | |
| 354 | 354 | palette := [16]renderer.RGB{} | |
| 355 | 355 | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 356 | 356 | palette[10] = palette[2] | |
| 357 | - | hostTheme := themeui.Theme{ | |
| 357 | + | accentTheme := themeui.Theme{ | |
| 358 | 358 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 359 | 359 | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 360 | 360 | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |
| 361 | 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") | |
| 362 | + | indexedTheme := accentTheme | |
| 363 | + | indexedTheme.TrueColor = false | |
| 364 | + | paletteOffTheme := accentTheme | |
| 365 | + | paletteOffTheme.UsePalette = false | |
| 366 | + | neutralTheme := accentTheme | |
| 367 | + | neutralTheme.UsePalette = false | |
| 368 | + | neutralTheme.PaletteKnown = 0 | |
| 369 | + | ||
| 370 | + | tests := []struct { | |
| 371 | + | name string | |
| 372 | + | theme themeui.Theme | |
| 373 | + | policy domain.ThemeAccent | |
| 374 | + | }{ | |
| 375 | + | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 376 | + | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 377 | + | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 378 | + | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 379 | + | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 380 | + | {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 381 | + | } | |
| 364 | 382 | ||
| 365 | 383 | model := New([]SessionView{ | |
| 366 | 384 | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |
| 367 | 385 | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 368 | 386 | }, "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") | |
| 387 | + | for _, tt := range tests { | |
| 388 | + | t.Run(tt.name, func(t *testing.T) { | |
| 389 | + | styles := themeui.Resolve(tt.theme, tt.policy).Styles | |
| 390 | + | require.Equal(t, styles.SurfaceInactive.Background, styles.PickerDescription.Background) | |
| 391 | + | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, styles.PickerDescription.HasBackgroundRGB) | |
| 392 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB) | |
| 393 | + | require.Equal(t, styles.SurfaceBar.Background, styles.PickerSeparator.Background) | |
| 394 | + | require.Equal(t, styles.SurfaceBar.HasBackgroundRGB, styles.PickerSeparator.HasBackgroundRGB) | |
| 395 | + | require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.PickerSeparator.BackgroundRGB) | |
| 396 | + | if tt.name == "indexed only" { | |
| 397 | + | require.Equal(t, 2, styles.PickerDescription.Foreground) | |
| 398 | + | require.Equal(t, 2, styles.PickerSeparator.Foreground) | |
| 399 | + | require.False(t, styles.PickerDescription.HasBackgroundRGB) | |
| 400 | + | require.False(t, styles.PickerSeparator.HasBackgroundRGB) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{ | |
| 404 | + | Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive, | |
| 405 | + | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | |
| 406 | + | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | |
| 407 | + | Separator: styles.PickerSeparator, | |
| 408 | + | }) | |
| 409 | + | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background") | |
| 410 | + | require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface") | |
| 411 | + | }) | |
| 412 | + | } | |
| 377 | 413 | } | |
| @@ -350,28 +350,64 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |||||
| 350 | 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") | ||
| 351 | } | 351 | } | ||
| 352 | 352 | ||||
| 353 | - | func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) { | 353 | + | func TestPickerDetailKeepsInactiveSurfaceAcrossAccentFallbacks(t *testing.T) { |
| 354 | palette := [16]renderer.RGB{} | 354 | palette := [16]renderer.RGB{} | ||
| 355 | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | 355 | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | ||
| 356 | palette[10] = palette[2] | 356 | palette[10] = palette[2] | ||
| 357 | - | hostTheme := themeui.Theme{ | 357 | + | accentTheme := themeui.Theme{ |
| 358 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | 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, | 359 | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | ||
| 360 | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | 360 | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | ||
| 361 | } | 361 | } | ||
| 362 | - | styles := themeui.Resolve(hostTheme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | 362 | + | indexedTheme := accentTheme |
| 363 | - | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB, "description must retain the inactive row surface") | 363 | + | indexedTheme.TrueColor = false |
| 364 | + | paletteOffTheme := accentTheme | |||
| 365 | + | paletteOffTheme.UsePalette = false | |||
| 366 | + | neutralTheme := accentTheme | |||
| 367 | + | neutralTheme.UsePalette = false | |||
| 368 | + | neutralTheme.PaletteKnown = 0 | |||
| 369 | + | ||||
| 370 | + | tests := []struct { | |||
| 371 | + | name string | |||
| 372 | + | theme themeui.Theme | |||
| 373 | + | policy domain.ThemeAccent | |||
| 374 | + | }{ | |||
| 375 | + | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |||
| 376 | + | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |||
| 377 | + | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 378 | + | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 379 | + | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 380 | + | {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |||
| 381 | + | } | |||
| 364 | 382 | ||||
| 365 | model := New([]SessionView{ | 383 | model := New([]SessionView{ | ||
| 366 | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | 384 | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | ||
| 367 | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | 385 | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | ||
| 368 | }, "selected", 0) | 386 | }, "selected", 0) | ||
| 369 | - | frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{ | 387 | + | for _, tt := range tests { |
| 370 | - | Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive, | 388 | + | t.Run(tt.name, func(t *testing.T) { |
| 371 | - | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | 389 | + | styles := themeui.Resolve(tt.theme, tt.policy).Styles |
| 372 | - | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | 390 | + | require.Equal(t, styles.SurfaceInactive.Background, styles.PickerDescription.Background) |
| 373 | - | Separator: styles.PickerSeparator, | 391 | + | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, styles.PickerDescription.HasBackgroundRGB) |
| 374 | - | }) | 392 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB) |
| 375 | - | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background") | 393 | + | require.Equal(t, styles.SurfaceBar.Background, styles.PickerSeparator.Background) |
| 376 | - | require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface") | 394 | + | require.Equal(t, styles.SurfaceBar.HasBackgroundRGB, styles.PickerSeparator.HasBackgroundRGB) |
| 395 | + | require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.PickerSeparator.BackgroundRGB) | |||
| 396 | + | if tt.name == "indexed only" { | |||
| 397 | + | require.Equal(t, 2, styles.PickerDescription.Foreground) | |||
| 398 | + | require.Equal(t, 2, styles.PickerSeparator.Foreground) | |||
| 399 | + | require.False(t, styles.PickerDescription.HasBackgroundRGB) | |||
| 400 | + | require.False(t, styles.PickerSeparator.HasBackgroundRGB) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{ | |||
| 404 | + | Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive, | |||
| 405 | + | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | |||
| 406 | + | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | |||
| 407 | + | Separator: styles.PickerSeparator, | |||
| 408 | + | }) | |||
| 409 | + | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background") | |||
| 410 | + | require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface") | |||
| 411 | + | }) | |||
| 412 | + | } | |||
| 377 | } | 413 | } | ||
M internal/usecase/theme/phase3_review_test.go +3 -0
| @@ -115,6 +115,9 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |||
| 115 | 115 | t.Run(tt.name, func(t *testing.T) { | |
| 116 | 116 | got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | |
| 117 | 117 | want := prePhase3LegacyAliases(tt.theme) | |
| 118 | + | // PickerSeparator is now a semantic role with the SurfaceBar | |
| 119 | + | // background; it was never a compatibility alias. | |
| 120 | + | delete(want, "PickerSeparator") | |
| 118 | 121 | for name, style := range want { | |
| 119 | 122 | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | |
| 120 | 123 | } | |
| @@ -115,6 +115,9 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |||||
| 115 | t.Run(tt.name, func(t *testing.T) { | 115 | t.Run(tt.name, func(t *testing.T) { | ||
| 116 | got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | 116 | got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | ||
| 117 | want := prePhase3LegacyAliases(tt.theme) | 117 | want := prePhase3LegacyAliases(tt.theme) | ||
| 118 | + | // PickerSeparator is now a semantic role with the SurfaceBar | |||
| 119 | + | // background; it was never a compatibility alias. | |||
| 120 | + | delete(want, "PickerSeparator") | |||
| 118 | for name, style := range want { | 121 | for name, style := range want { | ||
| 119 | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | 122 | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | ||
| 120 | } | 123 | } | ||
M internal/usecase/theme/resolved_styles.go +21 -5
| @@ -89,7 +89,7 @@ func indexedStyles(styles Styles, slot uint8) Styles { | |||
| 89 | 89 | // blue fallback too. Keep the strict explicit index on each foreground | |
| 90 | 90 | // decoration without changing any neutral surface background. | |
| 91 | 91 | styles.Border = styles.BorderMuted | |
| 92 | - | styles.PaletteDesc = styles.PickerDescription | |
| 92 | + | styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot) | |
| 93 | 93 | styles.TabTitle = styles.TabInactiveTitle | |
| 94 | 94 | styles.TabTitleActive = styles.TabActiveTitle | |
| 95 | 95 | styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot) | |
| @@ -109,12 +109,18 @@ func neutralStyles(t Theme) Styles { | |||
| 109 | 109 | status := renderer.DefaultStyle() | |
| 110 | 110 | accent := inverseStyle() | |
| 111 | 111 | border := renderer.DefaultStyle() | |
| 112 | - | muted := renderer.DefaultStyle() | |
| 112 | + | inactiveDescription := status | |
| 113 | + | barDescription := status | |
| 113 | 114 | if usable(t) { | |
| 114 | 115 | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |
| 115 | 116 | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |
| 116 | 117 | border = neutralBorderStyle(t) | |
| 117 | - | muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |
| 118 | + | // Preserve the legacy neutral muted foreground while retaining the | |
| 119 | + | // semantic row/bar background beneath description and separator cells. | |
| 120 | + | inactiveDescription = status | |
| 121 | + | inactiveDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 122 | + | barDescription = status | |
| 123 | + | barDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 118 | 124 | } | |
| 119 | 125 | styles := Styles{ | |
| 120 | 126 | SurfaceBar: status, | |
| @@ -132,13 +138,16 @@ func neutralStyles(t Theme) Styles { | |||
| 132 | 138 | ||
| 133 | 139 | PickerBase: status, | |
| 134 | 140 | PickerSelection: EmphasisStyle(accent, t), | |
| 135 | - | PickerDescription: muted, | |
| 136 | - | PickerSeparator: muted, | |
| 141 | + | PickerDescription: inactiveDescription, | |
| 142 | + | PickerSeparator: barDescription, | |
| 137 | 143 | PromptBase: status, | |
| 138 | 144 | CopyStatus: accent, | |
| 139 | 145 | SearchSelection: accent, | |
| 140 | 146 | } | |
| 141 | 147 | styles = withLegacyAliases(styles, t) | |
| 148 | + | // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy | |
| 149 | + | // foreground-only bytes; rendered palette descriptions use PickerDescription. | |
| 150 | + | styles.PaletteDesc = legacyMutedText(t) | |
| 142 | 151 | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | |
| 143 | 152 | // MRU entries deliberately share the neutral recent surface. | |
| 144 | 153 | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | |
| @@ -164,6 +173,13 @@ func NeutralBorderStyle(t Theme) renderer.Style { | |||
| 164 | 173 | ||
| 165 | 174 | func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) } | |
| 166 | 175 | ||
| 176 | + | func legacyMutedText(t Theme) renderer.Style { | |
| 177 | + | if !usable(t) { | |
| 178 | + | return renderer.DefaultStyle() | |
| 179 | + | } | |
| 180 | + | return foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |
| 181 | + | } | |
| 182 | + | ||
| 167 | 183 | func withLegacyAliases(styles Styles, t Theme) Styles { | |
| 168 | 184 | styles.StatusBar = styles.SurfaceBar | |
| 169 | 185 | styles.Accent = styles.SurfaceActive | |
| @@ -89,7 +89,7 @@ func indexedStyles(styles Styles, slot uint8) Styles { | |||||
| 89 | // blue fallback too. Keep the strict explicit index on each foreground | 89 | // blue fallback too. Keep the strict explicit index on each foreground | ||
| 90 | // decoration without changing any neutral surface background. | 90 | // decoration without changing any neutral surface background. | ||
| 91 | styles.Border = styles.BorderMuted | 91 | styles.Border = styles.BorderMuted | ||
| 92 | - | styles.PaletteDesc = styles.PickerDescription | 92 | + | styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot) |
| 93 | styles.TabTitle = styles.TabInactiveTitle | 93 | styles.TabTitle = styles.TabInactiveTitle | ||
| 94 | styles.TabTitleActive = styles.TabActiveTitle | 94 | styles.TabTitleActive = styles.TabActiveTitle | ||
| 95 | styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot) | 95 | styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot) | ||
| @@ -109,12 +109,18 @@ func neutralStyles(t Theme) Styles { | |||||
| 109 | status := renderer.DefaultStyle() | 109 | status := renderer.DefaultStyle() | ||
| 110 | accent := inverseStyle() | 110 | accent := inverseStyle() | ||
| 111 | border := renderer.DefaultStyle() | 111 | border := renderer.DefaultStyle() | ||
| 112 | - | muted := renderer.DefaultStyle() | 112 | + | inactiveDescription := status |
| 113 | + | barDescription := status | |||
| 113 | if usable(t) { | 114 | if usable(t) { | ||
| 114 | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | 115 | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | ||
| 115 | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | 116 | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | ||
| 116 | border = neutralBorderStyle(t) | 117 | border = neutralBorderStyle(t) | ||
| 117 | - | muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | 118 | + | // Preserve the legacy neutral muted foreground while retaining the |
| 119 | + | // semantic row/bar background beneath description and separator cells. | |||
| 120 | + | inactiveDescription = status | |||
| 121 | + | inactiveDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |||
| 122 | + | barDescription = status | |||
| 123 | + | barDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |||
| 118 | } | 124 | } | ||
| 119 | styles := Styles{ | 125 | styles := Styles{ | ||
| 120 | SurfaceBar: status, | 126 | SurfaceBar: status, | ||
| @@ -132,13 +138,16 @@ func neutralStyles(t Theme) Styles { | |||||
| 132 | 138 | ||||
| 133 | PickerBase: status, | 139 | PickerBase: status, | ||
| 134 | PickerSelection: EmphasisStyle(accent, t), | 140 | PickerSelection: EmphasisStyle(accent, t), | ||
| 135 | - | PickerDescription: muted, | 141 | + | PickerDescription: inactiveDescription, |
| 136 | - | PickerSeparator: muted, | 142 | + | PickerSeparator: barDescription, |
| 137 | PromptBase: status, | 143 | PromptBase: status, | ||
| 138 | CopyStatus: accent, | 144 | CopyStatus: accent, | ||
| 139 | SearchSelection: accent, | 145 | SearchSelection: accent, | ||
| 140 | } | 146 | } | ||
| 141 | styles = withLegacyAliases(styles, t) | 147 | styles = withLegacyAliases(styles, t) | ||
| 148 | + | // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy | |||
| 149 | + | // foreground-only bytes; rendered palette descriptions use PickerDescription. | |||
| 150 | + | styles.PaletteDesc = legacyMutedText(t) | |||
| 142 | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | 151 | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | ||
| 143 | // MRU entries deliberately share the neutral recent surface. | 152 | // MRU entries deliberately share the neutral recent surface. | ||
| 144 | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | 153 | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | ||
| @@ -164,6 +173,13 @@ func NeutralBorderStyle(t Theme) renderer.Style { | |||||
| 164 | 173 | ||||
| 165 | func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) } | 174 | func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) } | ||
| 166 | 175 | ||||
| 176 | + | func legacyMutedText(t Theme) renderer.Style { | |||
| 177 | + | if !usable(t) { | |||
| 178 | + | return renderer.DefaultStyle() | |||
| 179 | + | } | |||
| 180 | + | return foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 167 | func withLegacyAliases(styles Styles, t Theme) Styles { | 183 | func withLegacyAliases(styles Styles, t Theme) Styles { | ||
| 168 | styles.StatusBar = styles.SurfaceBar | 184 | styles.StatusBar = styles.SurfaceBar | ||
| 169 | styles.Accent = styles.SurfaceActive | 185 | styles.Accent = styles.SurfaceActive | ||