1package daemon
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/require"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"
13)
14
15func cacheTheme() themeui.Theme {
16 palette := [16]renderer.RGB{}
17 palette[2] = renderer.RGB{R: 30, G: 190, B: 150}
18 palette[10] = palette[2]
19 return themeui.Theme{
20 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
21 HasFG: true, HasBG: true, Known: true, UsePalette: true,
22 Palette: palette, PaletteKnown: 1<<2 | 1<<10,
23 }
24}
25
26func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) {
27 d, sess, ac, _ := newManualSessionWithPTYs(t)
28 raw := cacheTheme()
29 d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})
30 before := ac.getAppliedTheme()
31
32 cfg := domain.Defaults()
33 cfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}
34 d.ApplyConfig(cfg)
35 after := ac.getAppliedTheme()
36
37 require.Equal(t, before.Raw, after.Raw, "an accent-only reload retains the definitive client palette")
38 require.Greater(t, after.Generation, before.Generation)
39 require.Equal(t, themeui.Resolve(after.Raw, cfg.ThemeAccent), after.Resolved)
40 require.Equal(t, uint8(2), after.Resolved.Accent.Slot)
41}
42
43func TestComposeCacheInvalidatesOnStyleGenerationWithoutPaneRecoloring(t *testing.T) {
44 raw := cacheTheme()
45 first := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
46 second := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
47 pane := renderer.NewFrame(4, 1)
48 pane.Set(0, 0, renderer.Cell{Rune: 'P', Style: renderer.Style{Foreground: 196}})
49 state := capturedRenderState{
50 reset: false, theme: raw, styles: first.Styles, styleGeneration: 1,
51 layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "p", valid: true, fingerprint: "same", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}}},
52 panes: []capturedPaneRenderState{{id: "p", frame: pane, focused: true, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, damage: []renderer.Damage{renderer.FullRedraw()}}},
53 }
54 committed := composeFrame(state, composeCacheInput{})
55 state.reset, state.styles, state.styleGeneration, state.panes[0].damage = false, second.Styles, 2, nil
56 out := composeFrame(state, committed.cache, composeCacheInput{})
57
58 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
59 require.Equal(t, pane.At(0, 0), out.frame.At(0, 1), "captured application cells must not receive chrome styles")
60}
61
62func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
63 raw := cacheTheme()
64 resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
65 cell := renderer.Cell{Rune: 'X', Style: renderer.Style{Foreground: 196}}
66 focused, inactive := renderer.NewFrame(2, 1), renderer.NewFrame(2, 1)
67 focused.Set(0, 0, cell)
68 inactive.Set(0, 0, cell)
69 placements := []layout.Placement{{ID: "focused", Content: domain.Rect{Width: 2, Height: 1}}, {ID: "inactive", Content: domain.Rect{X: 2, Width: 2, Height: 1}}}
70 state := capturedRenderState{
71 reset: true, theme: raw, styles: resolved.Styles, styleGeneration: 1,
72 layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "focused", valid: true, placements: placements},
73 panes: []capturedPaneRenderState{{id: "focused", frame: focused, focused: true, placement: placements[0], damage: []renderer.Damage{renderer.FullRedraw()}}, {id: "inactive", frame: inactive, placement: placements[1], damage: []renderer.Damage{renderer.FullRedraw()}}},
74 }
75 out := composeFrame(state, composeCacheInput{})
76 require.Equal(t, cell, out.frame.At(0, 1))
77 require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style).Canonical(), out.frame.At(2, 1).Style.Canonical())
78}
79
80func 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).Canonical()
87 accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted).Canonical()
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, 2).Rune, "pane content must remain unchanged")
104}