main 5041353

manager_test.go RawHistoryFolder

1package theme
2
3import (
4	"context"
5	"testing"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/stretchr/testify/assert"
9	"github.com/stretchr/testify/require"
10)
11
12func resolvedThemeFixture(prefersDark bool) entity.ResolvedTheme {
13	light := entity.ColorPalette{
14		Background:     "#ffffff",
15		Surface:        "#f8f8f8",
16		SurfaceVariant: "#eeeeee",
17		Text:           "#111111",
18		Muted:          "#666666",
19		Accent:         "#0055ff",
20		Border:         "#dddddd",
21	}
22	dark := entity.ColorPalette{
23		Background:     "#101010",
24		Surface:        "#181818",
25		SurfaceVariant: "#242424",
26		Text:           "#f5f5f5",
27		Muted:          "#999999",
28		Accent:         "#66aaff",
29		Border:         "#333333",
30	}
31	active := light
32	if prefersDark {
33		active = dark
34	}
35	return entity.ResolvedTheme{
36		LightPalette:      light,
37		DarkPalette:       dark,
38		ActivePalette:     active,
39		PrefersDark:       prefersDark,
40		ColorSchemeSource: "test",
41		ThemeSource:       entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig},
42		Fonts: entity.ThemeFonts{
43			SansFont:      "Inter",
44			SerifFont:     "Georgia",
45			MonospaceFont: "JetBrains Mono",
46			GtkFont:       "Adwaita Sans",
47			DefaultSize:   16,
48		},
49		UIScale: 1.5,
50		ModeColors: entity.ThemeModeColors{
51			PaneMode:    "#ff0000",
52			TabMode:     "#00ff00",
53			SessionMode: "#0000ff",
54			ResizeMode:  "#ffff00",
55		},
56	}
57}
58
59func TestNewManager_UsesResolvedThemeFields(t *testing.T) {
60	ctx := context.Background()
61	resolved := resolvedThemeFixture(true)
62
63	manager := NewManager(ctx, resolved)
64
65	require.NotNil(t, manager)
66	assert.True(t, manager.PrefersDark())
67	assert.Equal(t, "#ffffff", manager.GetLightPalette().Background)
68	assert.Equal(t, "#101010", manager.GetDarkPalette().Background)
69	assert.Equal(t, manager.GetDarkPalette(), manager.GetCurrentPalette())
70	assert.Equal(t, "#ff0000", manager.GetModeColors().PaneMode)
71	assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, manager.fonts)
72	assert.Equal(t, "Adwaita Sans", manager.gtkFont)
73	assert.InDelta(t, 1.5, manager.uiScale, 0.000001)
74}
75
76func TestNewManager_DefaultsInvalidScaleToOne(t *testing.T) {
77	ctx := context.Background()
78	resolved := resolvedThemeFixture(true)
79	resolved.UIScale = 0
80
81	manager := NewManager(ctx, resolved)
82
83	require.NotNil(t, manager)
84	assert.InDelta(t, 1.0, manager.uiScale, 0.000001)
85}
86
87func TestManager_UpdateFromResolvedReplacesResolvedValues(t *testing.T) {
88	ctx := context.Background()
89	manager := NewManager(ctx, resolvedThemeFixture(true))
90	updated := resolvedThemeFixture(false)
91	updated.LightPalette.Background = "#abcdef"
92	updated.DarkPalette.Background = "#123456"
93	updated.Fonts.SansFont = "Recursive"
94	updated.UIScale = 2.0
95	updated.ModeColors.PaneMode = "#654321"
96
97	manager.UpdateFromResolved(ctx, updated, nil)
98
99	assert.False(t, manager.PrefersDark())
100	assert.Equal(t, "#abcdef", manager.GetLightPalette().Background)
101	assert.Equal(t, "#123456", manager.GetDarkPalette().Background)
102	assert.Equal(t, manager.GetLightPalette(), manager.GetCurrentPalette())
103	assert.Equal(t, "Recursive", manager.fonts.SansFont)
104	assert.InDelta(t, 2.0, manager.uiScale, 0.000001)
105	assert.Equal(t, "#654321", manager.GetModeColors().PaneMode)
106}
107
108func TestManager_GetWebUIThemeCSS(t *testing.T) {
109	ctx := context.Background()
110	manager := NewManager(ctx, resolvedThemeFixture(true))
111
112	css := manager.GetWebUIThemeCSS()
113
114	assert.Contains(t, css, ":root{")
115	assert.Contains(t, css, ".dark{")
116	assert.Contains(t, css, "--background: #ffffff")
117	assert.Contains(t, css, "--background: #101010")
118}
119
120func TestManager_GetBackgroundRGBAUsesCurrentPalette(t *testing.T) {
121	ctx := context.Background()
122	manager := NewManager(ctx, resolvedThemeFixture(true))
123
124	r, g, b, a := manager.GetBackgroundRGBA()
125
126	assert.InDelta(t, float32(0x10)/255, r, 0.0001)
127	assert.InDelta(t, float32(0x10)/255, g, 0.0001)
128	assert.InDelta(t, float32(0x10)/255, b, 0.0001)
129	assert.InDelta(t, float32(1), a, 0.0001)
130}
131
132func TestPaletteFromEntityAddsSemanticDefaults(t *testing.T) {
133	palette := PaletteFromEntity(entity.ColorPalette{Background: "#ffffff", Text: "#111111"}, false)
134
135	assert.Equal(t, "#ffffff", palette.Background)
136	assert.Equal(t, "#111111", palette.Text)
137	assert.Equal(t, DefaultLightPalette().Success, palette.Success)
138	assert.Equal(t, DefaultLightPalette().Warning, palette.Warning)
139	assert.Equal(t, DefaultLightPalette().Destructive, palette.Destructive)
140}
141
142func TestModeColorsFromEntity(t *testing.T) {
143	modeColors := ModeColorsFromEntity(entity.ThemeModeColors{
144		PaneMode:    "#111111",
145		TabMode:     "#222222",
146		SessionMode: "#333333",
147		ResizeMode:  "#444444",
148		VimMode:     "#abcdef",
149	})
150
151	assert.Equal(t, "#111111", modeColors.PaneMode)
152	assert.Equal(t, "#222222", modeColors.TabMode)
153	assert.Equal(t, "#333333", modeColors.SessionMode)
154	assert.Equal(t, "#444444", modeColors.ResizeMode)
155	assert.Equal(t, "#abcdef", modeColors.VimMode)
156}
157
158func TestFontConfigFromEntity(t *testing.T) {
159	fonts := FontConfigFromEntity(entity.ThemeFonts{
160		SansFont:      "Inter",
161		MonospaceFont: "JetBrains Mono",
162		GtkFont:       "Adwaita Sans",
163	})
164
165	assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, fonts)
166}
167
168func TestDefaultGTKFont(t *testing.T) {
169	assert.Equal(t, "Adwaita Sans", DefaultGTKFont())
170}
171
172func TestFormatGTKFontName_UsesScaledConfiguredGTKFont(t *testing.T) {
173	assert.Equal(t, "Adwaita Sans 14", formatGTKFontName("Adwaita Sans", 1.3))
174}
175
176func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) {
177	assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0))
178	assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05))
179	assert.Equal(t, "Adwaita Sans 11", formatGTKFontName("", 1.0))
180}
181
182func TestShouldApplyGTKFontName(t *testing.T) {
183	assert.True(t, shouldApplyGTKFontName("", "Fira Sans 14"))
184	assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "Fira Sans 14"))
185	assert.False(t, shouldApplyGTKFontName("Fira Sans 14", ""))
186}
187
188func TestShouldSkipThemeReapply(t *testing.T) {
189	const (
190		displayA  = uintptr(0x1000)
191		displayB  = uintptr(0x2000)
192		css       = "window{background:#000;}"
193		otherCSS  = "window{background:#fff;}"
194		font      = "Adwaita Sans 14"
195		otherFont = "Adwaita Sans 17"
196	)
197
198	// Same provider, display, CSS and font: skip the reload and re-add.
199	assert.True(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, font, font),
200		"repeated window creation with an unchanged theme must skip reapply")
201	// No provider yet: full apply.
202	assert.False(t, shouldSkipThemeReapply(false, 0, displayA, "", css, "", font))
203	// Different display: the provider must be added for the new display even
204	// when the CSS is unchanged. Displays are never conflated.
205	assert.False(t, shouldSkipThemeReapply(true, displayA, displayB, css, css, font, font))
206	// Changed CSS (real theme change): reload.
207	assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, otherCSS, font, font))
208	// Changed font (scale change): reapply for the font setting.
209	assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, font, otherFont))
210	// Empty font identity never skips.
211	assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, "", ""))
212}
213
214func TestEffectiveCSSIdentity_StableForUnchangedTheme(t *testing.T) {
215	ctx := context.Background()
216	first := NewManager(ctx, resolvedThemeFixture(true))
217	second := NewManager(ctx, resolvedThemeFixture(true))
218
219	cssOf := func(m *Manager) string {
220		return GenerateCSSFullWithTiming(m.GetCurrentPalette(), m.uiScale, m.fonts, m.modeColors, m.transitionDurationMs)
221	}
222
223	// The dedup relies on CSS identity: identical resolved themes must
224	// produce identical CSS so repeated window creation skips the reload.
225	assert.Equal(t, cssOf(first), cssOf(second))
226	assert.NotEmpty(t, cssOf(first))
227
228	// A real theme change must change the identity so it is never skipped.
229	changed := resolvedThemeFixture(true)
230	changed.DarkPalette.Background = "#000001"
231	other := NewManager(ctx, changed)
232	assert.NotEqual(t, cssOf(first), cssOf(other))
233}
234
235func TestManager_AppliedFontTrackedPerDisplay(t *testing.T) {
236	ctx := context.Background()
237	m := NewManager(ctx, resolvedThemeFixture(true))
238
239	const (
240		displayA = uintptr(0x1000)
241		displayB = uintptr(0x2000)
242		font     = "Adwaita Sans 14"
243	)
244
245	// No font recorded anywhere initially, including on a nil manager.
246	assert.Empty(t, m.lastAppliedFontFor(displayA))
247	assert.Empty(t, m.lastAppliedFontFor(displayB))
248	assert.Empty(t, (*Manager)(nil).lastAppliedFontFor(displayA))
249
250	// Recording for one display leaves the other untouched so its
251	// GtkSettings still get configured on first apply.
252	m.recordAppliedFontFor(displayA, font)
253	assert.Equal(t, font, m.lastAppliedFontFor(displayA))
254	assert.Empty(t, m.lastAppliedFontFor(displayB))
255
256	m.recordAppliedFontFor(displayB, font)
257	assert.Equal(t, font, m.lastAppliedFontFor(displayB))
258
259	// Updating one display does not disturb the other.
260	m.recordAppliedFontFor(displayA, "Adwaita Sans 17")
261	assert.Equal(t, "Adwaita Sans 17", m.lastAppliedFontFor(displayA))
262	assert.Equal(t, font, m.lastAppliedFontFor(displayB))
263
264	// Recording on a nil manager is a safe no-op.
265	assert.NotPanics(t, func() { (*Manager)(nil).recordAppliedFontFor(displayA, font) })
266}

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 8

Tags 42

↑↓ moveenter openesc close