1package daemon
2
3import (
4 "regexp"
5 "strconv"
6 "strings"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10
11 renderer "github.com/bnema/vev-vt"
12 ansirenderer "github.com/bnema/vev-vt/ansi"
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/protocol"
16 "github.com/bnema/vev/internal/usecase/colorprofile"
17 "github.com/bnema/vev/internal/usecase/layout"
18 themeui "github.com/bnema/vev/internal/usecase/theme"
19 "github.com/bnema/vev/internal/usecase/ui"
20)
21
22var sgrSequence = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
23
24// colorParams returns every color-bearing SGR parameter group in out, e.g.
25// "31", "48;2;1;2;3", "58;5;9". Attribute parameters (0, 1, 2, 3, 4, 7...) are
26// not reported.
27func colorParams(out []byte) []string {
28 var found []string
29 for _, m := range sgrSequence.FindAllStringSubmatch(string(out), -1) {
30 params := strings.Split(m[1], ";")
31 for i := 0; i < len(params); i++ {
32 n, err := strconv.Atoi(params[i])
33 if err != nil {
34 continue
35 }
36 switch {
37 case n == 38 || n == 48 || n == 58:
38 end := len(params)
39 if i+1 < len(params) && params[i+1] == "2" {
40 end = min(i+5, len(params))
41 } else if i+1 < len(params) {
42 end = min(i+3, len(params))
43 }
44 found = append(found, strings.Join(params[i:end], ";"))
45 i = end - 1
46 case n >= 30 && n <= 37, n >= 40 && n <= 47, n >= 90 && n <= 97, n >= 100 && n <= 107:
47 found = append(found, params[i])
48 }
49 }
50 }
51 return found
52}
53
54func reducedTestTheme() themeui.Theme {
55 palette := [16]renderer.RGB{}
56 palette[4] = renderer.RGB{R: 60, G: 120, B: 220}
57 palette[12] = palette[4]
58 return themeui.Theme{
59 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
60 HasFG: true, HasBG: true, Known: true, UsePalette: true, SchemeKnown: true,
61 Palette: palette, PaletteKnown: 1<<4 | 1<<12,
62 }
63}
64
65// reducedChromeFrame composes a representative frame: two tabs (second
66// active) with an attention bell, a split with the left pane focused, a
67// bottom bar with MRU entries, a toast, and a focused palette modal with a
68// selected row. rawTheme is what the attachment reported.
69func reducedChromeFrame(t *testing.T, color terminalcap.ColorCapabilities) (composedRenderFrame, themeui.ResolvedTheme) {
70 t.Helper()
71 resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
72 state := cachedSplitState("reduced", "left", layout.Horizontal, resolved.Theme)
73 state.styles = resolved.Styles
74 state.bars = barState{
75 status: statusSnapshot{session: "work", tabs: []statusTab{{name: "one", paneTitle: "sh"}, {name: "two", paneTitle: "vim", active: true, attention: true}}},
76 attentionFrame: pulseFrameCount / 2,
77 mru: []recentRouteDisplay{{name: "alp"}, {name: "beta"}},
78 theme: resolved.Theme,
79 }
80 state.panes[0].title, state.panes[1].title = "left", "right"
81 state.overlays.notices = []domain.Notification{{Code: domain.NoticeUser, Severity: domain.NoticeWarn, Message: "careful", Count: 1}}
82 inner := renderer.NewFrame(18, 3)
83 for x, r := range "> sel" {
84 inner.Set(x, 0, renderer.Cell{Rune: r, Style: resolved.Styles.PickerSelection})
85 }
86 state.overlays.paletteActive = true
87 state.overlays.palette = capturedModal{
88 active: true, title: "Palette", focused: true, inner: inner,
89 presentation: ui.Presentation{Bounds: domain.Rect{X: 10, Y: 2, Width: 20, Height: 5}, Inner: domain.Rect{X: 11, Y: 3, Width: 18, Height: 3}, Borders: ui.BorderAll},
90 }
91 return composeFrame(state, composeCacheInput{}), resolved
92}
93
94// rowIndex returns the x of the first cell of text on row y.
95func rowIndex(frame renderer.Frame, y int, text string) int {
96 var row strings.Builder
97 for x := range frame.Width {
98 r := frame.At(x, y).Rune
99 if r == 0 {
100 r = ' '
101 }
102 row.WriteRune(r)
103 }
104 return strings.Index(row.String(), text)
105}
106
107func drawProfile(t *testing.T, frame renderer.Frame, color terminalcap.ColorCapabilities) []byte {
108 t.Helper()
109 out, err := ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, colorprofile.Profile(color)).Draw(frame, []renderer.Damage{renderer.FullRedraw()})
110 require.NoError(t, err)
111 return out
112}
113
114func TestReducedColorChromeIsLegible(t *testing.T) {
115 ansi16 := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}
116 mono := terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}
117
118 t.Run("ansi16 has no RGB, indexed or underline colors outside slots 0-15", func(t *testing.T) {
119 composed, resolved := reducedChromeFrame(t, ansi16)
120 out := drawProfile(t, composed.frame, ansi16)
121 for _, p := range colorParams(out) {
122 require.NotRegexp(t, `^(38|48|58)`, p, "ansi16 chrome emitted extended color %q", p)
123 }
124 // The accent is a palette slot, never RGB: the border and bell use it.
125 require.True(t, resolved.Accent.Known)
126 require.Contains(t, colorParams(out), "34", "accent slot 4 renders as the terminal's own blue")
127 // Chrome surfaces are the terminal background: no RGB backgrounds.
128 for x := range composed.frame.Width {
129 for _, y := range []int{0, composed.frame.Height - 1} {
130 require.False(t, composed.frame.At(x, y).Style.HasBackgroundRGB)
131 }
132 }
133 })
134
135 t.Run("monochrome has no color sequences at all", func(t *testing.T) {
136 composed, _ := reducedChromeFrame(t, mono)
137 out := drawProfile(t, composed.frame, mono)
138 require.Empty(t, colorParams(out), "%q", out)
139 require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)`, string(out))
140 })
141
142 for name, color := range map[string]terminalcap.ColorCapabilities{"ansi16": ansi16, "monochrome": mono} {
143 t.Run(name+" distinguishes active, selected and focused elements", func(t *testing.T) {
144 composed, resolved := reducedChromeFrame(t, color)
145 frame := composed.frame
146 // Top bar: " one" (inactive) vs " two" (active).
147 inactive, active := frame.At(rowIndex(frame, 0, "one"), 0).Style, frame.At(rowIndex(frame, 0, "two"), 0).Style
148 require.True(t, active.Inverse && active.Bold, "active tab must be bold reverse")
149 require.False(t, inactive.Inverse)
150 require.NotEqual(t, inactive.Canonical(), active.Canonical())
151 // The attention bell is bold on its visible beat.
152 bell := frame.At(rowIndex(frame, 0, string(ui.AttentionGlyph)), 0).Style
153 require.True(t, bell.Bold && bell.Inverse)
154 // Bottom bar: the session name is the active surface.
155 require.True(t, frame.At(rowIndex(frame, frame.Height-1, "work"), frame.Height-1).Style.Inverse)
156 require.False(t, frame.At(rowIndex(frame, frame.Height-1, "alp"), frame.Height-1).Style.Inverse)
157 // Focused pane title bar is plain reverse (no stack title bars in
158 // this fixture, so assert the role the title bar draws with).
159 require.True(t, resolved.Styles.StatusBar.Inverse)
160 // Palette selection row and focused modal border.
161 require.True(t, frame.At(11, 3).Style.Inverse && frame.At(11, 3).Style.Bold, "selected row must be bold reverse")
162 border := frame.At(10, 2).Style
163 require.True(t, border.Bold, "focused modal border must be bold")
164 require.Zero(t, border.Attrs&ansirenderer.AttrDim)
165 // The same border, unfocused, is faint: focus is attribute-visible.
166 require.NotZero(t, resolved.Styles.BorderMuted.Attrs&ansirenderer.AttrDim)
167 // Copy selection is plain reverse.
168 require.True(t, resolved.Styles.Selection.Inverse)
169 require.True(t, resolved.Styles.CopyStatus.Inverse)
170 // Toast box: warn border is visible without color.
171 require.True(t, resolved.Styles.BorderWarn.Bold)
172 })
173 }
174}
175
176// TestReducedColorLeavesPaneContentAlone guards that panes are never recolored
177// or faded: vev-vt quantizes them, vev chrome must not touch them.
178func TestReducedColorLeavesPaneContentAlone(t *testing.T) {
179 for _, mode := range []terminalcap.ColorMode{terminalcap.ANSI16, terminalcap.Monochrome} {
180 color := terminalcap.ColorCapabilities{Mode: mode}
181 resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
182 state := cachedSplitState("pane", "left", layout.Horizontal, resolved.Theme)
183 state.styles = resolved.Styles
184 pane := state.panes[1].frame // unfocused
185 cell := pane.At(0, 0)
186 cell.Style.Foreground = 196
187 pane.Set(0, 0, cell)
188 composed := composeFrame(state, composeCacheInput{})
189 x := state.panes[1].placement.Content.X
190 y := state.panes[1].placement.Content.Y + 1
191 require.Equal(t, cell.Style.Canonical(), composed.frame.At(x, y).Style.Canonical())
192 }
193}
194
195// TestResolveForColorKeepsRGBModesIdentical proves TrueColor and ANSI256
196// attachments resolve exactly as before this policy existed, so their output
197// bytes cannot change.
198func TestResolveForColorKeepsRGBModesIdentical(t *testing.T) {
199 raw := reducedTestTheme()
200 policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
201 want := themeui.Resolve(raw, policy)
202 for _, color := range []terminalcap.ColorCapabilities{{}, {Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}, {Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}} {
203 require.Equal(t, want, themeui.ResolveForColor(raw, policy, color))
204 }
205 for _, color := range []terminalcap.ColorCapabilities{{Mode: terminalcap.TrueColor}, {Mode: terminalcap.ANSI256}} {
206 composed, _ := reducedChromeFrame(t, color)
207 state := cachedSplitState("reference", "left", layout.Horizontal, raw)
208 _ = state
209 out := drawProfile(t, composed.frame, color)
210 require.Regexp(t, `(38|48);[25];`, string(out), "RGB-capable modes keep tinted surfaces")
211 }
212}
213
214// TestAppliedThemeIsPerAttachment confirms two attachments of one session
215// resolve independently.
216func TestAppliedThemeIsPerAttachment(t *testing.T) {
217 p, release := newBlockingPTY(t)
218 defer release()
219 d, sess, ac16, _ := newManualSessionWithPTYs(t, p)
220 ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}
221 tr, _ := newCapturingTransport(t)
222 acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
223 acTrue.output.attachment = acTrue
224 acTrue.initOverlays()
225 sess.mu.Lock()
226 sess.attachments[acTrue] = struct{}{}
227 sess.mu.Unlock()
228 acTrue.setSession(sess)
229
230 raw := reducedTestTheme()
231 msg := protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}
232 d.applyTheme(sess, ac16, msg)
233 d.applyTheme(sess, acTrue, msg)
234 d.reapplyThemeSession(sess) // a config reload must keep each mode
235
236 reduced, rgb := ac16.getAppliedTheme(), acTrue.getAppliedTheme()
237 require.True(t, reduced.Raw.DimByAttribute)
238 require.False(t, rgb.Raw.DimByAttribute)
239 require.False(t, reduced.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
240 require.True(t, rgb.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
241 require.Equal(t, themeui.Resolve(rgb.Raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, rgb.Resolved.Styles)
242}