theme_cache_test.go RawHistoryFolder

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/domain/layout"
11	"github.com/bnema/vev/internal/protocol"
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}

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