main ece7682

reduced_color_test.go RawHistoryFolder

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/layout"
15	"github.com/bnema/vev/internal/domain/terminalcap"
16	"github.com/bnema/vev/internal/protocol"
17	"github.com/bnema/vev/internal/usecase/colorprofile"
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(), geometry: domain.Geometry{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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close