v0.33.2 57f21d6

mode_toaster_layout_test.go RawHistoryFolder

1package ui
2
3import (
4	"testing"
5
6	"github.com/bnema/dumber/internal/domain/entity"
7	"github.com/bnema/dumber/internal/ui/input"
8	"github.com/stretchr/testify/require"
9)
10
11func TestLegendColumnCount(t *testing.T) {
12	for _, tc := range []struct {
13		width, want int
14	}{
15		{0, 1},
16		{200, 1},
17		{legendPanelPadding + legendMinColumnWidth, 1},
18		{legendPanelPadding + legendMinColumnWidth + legendColumnGap + legendMinColumnWidth - 1, 1},
19		{legendPanelPadding + 2*legendMinColumnWidth + legendColumnGap, 2},
20		{legendPanelPadding + 3*legendMinColumnWidth + 2*legendColumnGap, 3},
21		{legendPanelPadding + 4*legendMinColumnWidth + 3*legendColumnGap, 4},
22		{legendMaxWidth, 4},
23		{5000, legendMaxColumns},
24	} {
25		require.Equal(t, tc.want, legendColumnCount(tc.width), "width %d", tc.width)
26	}
27}
28
29func TestLegendColumnCountIsMonotonic(t *testing.T) {
30	previous := 0
31	for width := 0; width <= 2000; width++ {
32		got := legendColumnCount(width)
33		require.GreaterOrEqual(t, got, previous, "width %d", width)
34		require.GreaterOrEqual(t, got, 1)
35		require.LessOrEqual(t, got, legendMaxColumns)
36		previous = got
37	}
38}
39
40func TestLegendBandWidthIsCappedAndNeverFullWidth(t *testing.T) {
41	width, columns := legendBandWidth(2400, 10)
42	require.Equal(t, legendMaxWidth, width)
43	require.Equal(t, 4, columns)
44
45	width, columns = legendBandWidth(800, 10)
46	require.Equal(t, 800, width)
47	require.Equal(t, legendColumnCount(800), columns)
48
49	width, columns = legendBandWidth(300, 10)
50	require.Equal(t, 300, width)
51	require.Equal(t, 1, columns)
52}
53
54func TestLegendBandWidthShrinksForFewGroups(t *testing.T) {
55	width, columns := legendBandWidth(2400, 2)
56	require.Equal(t, 2, columns)
57	require.Less(t, width, legendMaxWidth)
58	require.GreaterOrEqual(t, legendColumnCount(width), columns, "the shrunk band still fits its columns")
59
60	width, columns = legendBandWidth(2400, 1)
61	require.Equal(t, 1, columns)
62	require.GreaterOrEqual(t, width, legendMinColumnWidth+legendPanelPadding)
63}
64
65func TestLegendBandWidthWithoutRoom(t *testing.T) {
66	width, columns := legendBandWidth(0, 5)
67	require.Zero(t, width)
68	require.Zero(t, columns)
69	width, columns = legendBandWidth(500, 0)
70	require.Zero(t, width)
71	require.Zero(t, columns)
72}
73
74func TestLegendBodyMaxHeight(t *testing.T) {
75	require.Equal(t, 1000*45/100-legendChromeHeight, legendBodyMaxHeight(1000))
76	require.Equal(t, legendMinBodyHeight, legendBodyMaxHeight(100), "a tiny window still shows a few rows")
77}
78
79// columnsOf flattens a distribution back into group order.
80func flattenDistribution(columns [][]int) []int {
81	var out []int
82	for _, column := range columns {
83		out = append(out, column...)
84	}
85	return out
86}
87
88func tallest(heights []int, columns [][]int) int {
89	best := 0
90	for _, column := range columns {
91		sum := 0
92		for _, i := range column {
93			sum += heights[i]
94		}
95		best = max(best, sum)
96	}
97	return best
98}
99
100func TestDistributeLegendGroupsKeepsOrderAndNeverSplitsGroups(t *testing.T) {
101	heights := []int{4, 1, 7, 3, 2, 9, 2, 5, 1, 3}
102	want := make([]int, len(heights))
103	for i := range want {
104		want[i] = i
105	}
106	for columns := 1; columns <= legendMaxColumns; columns++ {
107		got := distributeLegendGroups(heights, columns)
108		require.Len(t, got, columns)
109		require.Equal(t, want, flattenDistribution(got), "columns=%d keeps the fixed group order, each group once", columns)
110		for _, column := range got {
111			require.NotEmpty(t, column, "no empty column")
112		}
113	}
114}
115
116func TestDistributeLegendGroupsBalancesColumnHeights(t *testing.T) {
117	// One 8-row group next to four 1-row groups, in two columns: the tall
118	// group alone is the tallest column, the rest share the other.
119	heights := []int{8, 2, 2, 2, 2}
120	got := distributeLegendGroups(heights, 2)
121	require.Equal(t, [][]int{{0}, {1, 2, 3, 4}}, got)
122	require.Equal(t, 8, tallest(heights, got))
123
124	// Even groups split evenly.
125	got = distributeLegendGroups([]int{3, 3, 3, 3}, 2)
126	require.Equal(t, [][]int{{0, 1}, {2, 3}}, got)
127}
128
129func TestDistributeLegendGroupsMinimizesTallestColumn(t *testing.T) {
130	heights := []int{5, 1, 1, 1, 6, 2, 2, 4}
131	for columns := 1; columns <= 4; columns++ {
132		got := distributeLegendGroups(heights, columns)
133		require.Equal(t, bruteForceTallest(heights, columns), tallest(heights, got), "columns=%d", columns)
134	}
135}
136
137// bruteForceTallest tries every way to cut heights into columns runs.
138func bruteForceTallest(heights []int, columns int) int {
139	if columns == 1 {
140		sum := 0
141		for _, h := range heights {
142			sum += h
143		}
144		return sum
145	}
146	best := -1
147	sum := 0
148	for cut := 1; cut <= len(heights)-columns+1; cut++ {
149		sum += heights[cut-1]
150		rest := bruteForceTallest(heights[cut:], columns-1)
151		candidate := max(sum, rest)
152		if best < 0 || candidate < best {
153			best = candidate
154		}
155	}
156	return best
157}
158
159func TestDistributeLegendGroupsIsDeterministic(t *testing.T) {
160	heights := []int{2, 2, 2, 2, 2, 2}
161	first := distributeLegendGroups(heights, 3)
162	for range 20 {
163		require.Equal(t, first, distributeLegendGroups(heights, 3))
164	}
165	require.Equal(t, [][]int{{0, 1}, {2, 3}, {4, 5}}, first)
166}
167
168func TestDistributeLegendGroupsEdgeCases(t *testing.T) {
169	require.Nil(t, distributeLegendGroups(nil, 3))
170	require.Nil(t, distributeLegendGroups([]int{2, 3}, 0))
171	// More columns than groups: one group per column.
172	require.Equal(t, [][]int{{0}, {1}}, distributeLegendGroups([]int{2, 3}, 4))
173	require.Equal(t, [][]int{{0}}, distributeLegendGroups([]int{7}, 3))
174	require.Equal(t, [][]int{{0, 1, 2}}, distributeLegendGroups([]int{1, 1, 1}, 1))
175}
176
177func TestBuildLegendGroupsOrdersGroupsAndRows(t *testing.T) {
178	actions := map[string]entity.ActionBinding{
179		"enter-tab-mode":  {Keys: []string{"t"}, Desc: "Tab mode"},
180		"split-right":     {Keys: []string{"r"}},
181		"split-left":      {Keys: []string{"l"}},
182		"focus-up":        {Keys: []string{"k"}},
183		"confirm":         {Keys: []string{"enter"}},
184		"close-pane":      {Keys: []string{"x"}},
185		"unbound":         {},
186		"resize-increase": {Keys: []string{"+"}},
187	}
188	groups := buildLegendGroups(input.ModePane, actions)
189	var names []string
190	for _, group := range groups {
191		names = append(names, group.Name)
192	}
193	require.Equal(t, []string{"SPLIT", "FOCUS", "RESIZE", "MANAGE", "MODES", "EXIT"}, names)
194	require.Equal(t, "split left", groups[0].Rows[0].Desc, "rows are sorted and fall back to the action name")
195	require.Equal(t, "split-left", groups[0].Rows[0].Action)
196	require.Equal(t, "Tab mode", groups[4].Rows[0].Desc)
197	require.Equal(t, []int{3, 2, 2, 2, 2, 2}, legendGroupHeights(groups))
198}
199
200func TestBuildLegendGroupsEmpty(t *testing.T) {
201	require.Empty(t, buildLegendGroups(input.ModeVim, nil))
202	require.Empty(t, buildLegendGroups(input.ModeVim, map[string]entity.ActionBinding{"visual": {}}))
203}
204
205func TestModeToasterVisibility(t *testing.T) {
206	on := entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "delay"}
207	require.True(t, modeToasterVisible(input.ModeVim, on))
208	require.False(t, modeToasterVisible(input.ModeNormal, on))
209
210	// The indicator preference off, legend on: the pill is the legend's header.
211	headerOnly := entity.WorkspaceStylingConfig{ModeLegend: "always"}
212	require.True(t, modeToasterVisible(input.ModePane, headerOnly))
213
214	// Both off: nothing.
215	require.False(t, modeToasterVisible(input.ModePane, entity.WorkspaceStylingConfig{ModeLegend: "off"}))
216	// Legend off, indicator on: plain pill.
217	require.True(t, modeToasterVisible(input.ModePane, entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"}))
218}
219
220func TestLegendBodyShown(t *testing.T) {
221	require.True(t, legendBodyShown(true, false, false))
222	require.False(t, legendBodyShown(false, false, false), "show delay not elapsed")
223	require.False(t, legendBodyShown(true, true, false), "collapsed")
224	require.False(t, legendBodyShown(true, false, true), "suspended by a page interaction")
225}
226
227func TestNextModeLegendCollapsed(t *testing.T) {
228	require.True(t, nextModeLegendCollapsed(false))
229	require.False(t, nextModeLegendCollapsed(true))
230}

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