feat/data-dnd e05cb15

cascade_moves_test.go RawHistoryFolder

1package core
2
3import (
4	"slices"
5	"testing"
6)
7
8// cascadeMovesCore is a core whose focused screen holds a cascade of
9// windows 1..n in bands of maxCols. With sides, screens L and R sit on
10// either side of it; otherwise it is alone.
11func cascadeMovesCore(t *testing.T, n, maxCols int, sides bool) (*Core, *Workspace) {
12	t.Helper()
13	tiles := []tile{{"M", 900, 0, 900, 600}}
14	focus := 0
15	if sides {
16		tiles = []tile{{"L", 0, 0, 900, 600}, tiles[0], {"R", 1800, 0, 900, 600}}
17		focus = 1
18	}
19	c := layoutCore(focus, tiles...)
20	w := c.cur().mon.Current()
21	w.Overflow = OverflowCascade
22	w.SetMaxColumns(maxCols)
23	for id := WindowID(1); id <= WindowID(n); id++ {
24		w.AddWindow(id)
25	}
26	c.cur().mon.normalize() // the trailing empty workspace, as in a session
27	return c, w
28}
29
30// columnOrder is the first window of each column, in column order.
31func columnOrder(w *Workspace) []WindowID {
32	var ids []WindowID
33	for _, c := range w.Columns {
34		ids = append(ids, c.Windows[0])
35	}
36	return ids
37}
38
39func screenWorkspace(c *Core, name string) *Workspace {
40	for _, s := range c.screens {
41		if s.name() == name {
42			return s.mon.Current()
43		}
44	}
45	return nil
46}
47
48func wantColumns(t *testing.T, w *Workspace, want ...WindowID) {
49	t.Helper()
50	if got := columnOrder(w); !slices.Equal(got, want) {
51		t.Fatalf("columns %v, want %v", got, want)
52	}
53}
54
55// wantBands checks the cascade bands, each by the first window of its
56// columns.
57func wantBands(t *testing.T, w *Workspace, want ...[]WindowID) {
58	t.Helper()
59	var got [][]WindowID
60	w.walkBands(func(_, first, last int) bool {
61		got = append(got, columnOrder(&Workspace{Columns: w.Columns[first : last+1]}))
62		return true
63	})
64	if !slices.EqualFunc(got, want, slices.Equal) {
65		t.Fatalf("bands %v, want %v", got, want)
66	}
67}
68
69func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {
70	t.Helper()
71	if got := c.cur().name(); got != screen {
72		t.Fatalf("focused screen %q, want %q", got, screen)
73	}
74	if got, _ := c.cur().mon.Current().Focused(); got != id {
75		t.Fatalf("focused window %d, want %d", got, id)
76	}
77}
78
79// At the edge of its band, a cascade column goes to the neighbor monitor
80// on that side, and focus with it; bands {1 2 3} {4 5}.
81func TestCascadeBandEdgeMovesToNeighborMonitor(t *testing.T) {
82	for _, tc := range []struct {
83		name         string
84		from         WindowID
85		action       Action
86		screen       string
87		here, moved  []WindowID
88		focusedAfter WindowID
89	}{
90		{"move left from band start", 4, ActionMoveColumnLeft, "L", []WindowID{1, 2, 3, 5}, []WindowID{4}, 4},
91		{"move right from band end", 3, ActionMoveColumnRight, "R", []WindowID{1, 2, 4, 5}, []WindowID{3}, 3},
92		{"move right from last column", 5, ActionMoveColumnRight, "R", []WindowID{1, 2, 3, 4}, []WindowID{5}, 5},
93		{"focus left from band start", 4, ActionFocusColumnLeft, "L", []WindowID{1, 2, 3, 4, 5}, nil, 0},
94		{"focus right from band end", 3, ActionFocusColumnRight, "R", []WindowID{1, 2, 3, 4, 5}, nil, 0},
95	} {
96		t.Run(tc.name, func(t *testing.T) {
97			c, w := cascadeMovesCore(t, 5, 3, true)
98			w.FocusID(tc.from)
99			c.applyAction(tc.action)
100			wantColumns(t, w, tc.here...)
101			wantColumns(t, screenWorkspace(c, tc.screen), tc.moved...)
102			wantFocus(t, c, tc.screen, tc.focusedAfter)
103		})
104	}
105}
106
107// Inside a band, left/right stay on the screen.
108func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
109	c, w := cascadeMovesCore(t, 5, 3, true)
110	w.FocusID(5)
111	c.applyAction(ActionMoveColumnLeft)
112	wantColumns(t, w, 1, 2, 3, 5, 4)
113	wantFocus(t, c, "M", 5)
114	c.applyAction(ActionFocusColumnRight)
115	wantFocus(t, c, "M", 4)
116}
117
118// With no monitor on that side, left/right never cross into the neighbor
119// band: at the band edge focus steps the workspace (the first has none
120// before it; the last is the trailing empty one) and move-column does
121// nothing. up/down cross bands.
122func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveStays(t *testing.T) {
123	c, w := cascadeMovesCore(t, 5, 3, false)
124	m := c.cur().mon
125	w.FocusID(4)
126	c.applyAction(ActionFocusColumnLeft)
127	wantFocus(t, c, "M", 4)
128	if m.Active != 0 {
129		t.Fatalf("active %d, want 0", m.Active)
130	}
131	w.FocusID(3)
132	c.applyAction(ActionFocusColumnRight)
133	if m.Active != 1 || m.Current() == w {
134		t.Fatalf("active %d, want the trailing empty workspace", m.Active)
135	}
136	m.Focus(0)
137	w.FocusID(4)
138	c.applyAction(ActionMoveColumnLeft)
139	wantColumns(t, w, 1, 2, 3, 4, 5)
140	wantFocus(t, c, "M", 4)
141	w.FocusID(3)
142	c.applyAction(ActionMoveColumnRight)
143	wantColumns(t, w, 1, 2, 3, 4, 5)
144	wantFocus(t, c, "M", 3)
145}
146
147// With one column per band, every column is at both edges of its band.
148func TestCascadeOneColumnBandsLeaveForMonitor(t *testing.T) {
149	c, w := cascadeMovesCore(t, 2, 1, true)
150	w.FocusID(2)
151	c.applyAction(ActionMoveColumnLeft)
152	wantColumns(t, w, 1)
153	wantColumns(t, screenWorkspace(c, "L"), 2)
154	wantFocus(t, c, "L", 2)
155}
156
157// Fixed overflow keeps its column order edge: a non-edge column swaps.
158func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) {
159	c, w := cascadeMovesCore(t, 2, 3, true)
160	w.Overflow = OverflowFixed
161	w.FocusID(2)
162	c.applyAction(ActionMoveColumnLeft)
163	wantColumns(t, w, 2, 1)
164	wantFocus(t, c, "M", 2)
165}
166
167// At the top or bottom of its column, move-window-up/down sends a cascade
168// column to the adjacent band: it joins it when it has room, at its offset
169// in the band, else it makes a new band between the two.
170func TestCascadeMoveWindowCrossesBands(t *testing.T) {
171	c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
172	w.FocusID(2)
173	c.applyAction(ActionMoveWindowDown)
174	wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
175	wantFocus(t, c, "M", 2)
176	c.applyAction(ActionMoveWindowUp)
177	wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5})
178	wantFocus(t, c, "M", 2)
179}
180
181// A full band cannot take the column: a new band goes between the two,
182// and the one it left keeps the others.
183func TestCascadeMoveWindowIntoFullBandMakesBand(t *testing.T) {
184	c, w := cascadeMovesCore(t, 6, 3, false) // {1 2 3} {4 5 6}
185	w.FocusID(5)
186	c.applyAction(ActionMoveWindowUp)
187	wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5}, []WindowID{4, 6})
188	wantFocus(t, c, "M", 5)
189	// Alone in its band, it passes the full band above.
190	c.applyAction(ActionMoveWindowUp)
191	wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}, []WindowID{4, 6})
192	wantFocus(t, c, "M", 5)
193}
194
195// Past the outer bands a column sharing its band starts a new one; a
196// column alone there does not move.
197func TestCascadeMoveWindowOuterBands(t *testing.T) {
198	c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
199	w.FocusID(5)
200	c.applyAction(ActionMoveWindowDown)
201	wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
202	c.applyAction(ActionMoveWindowDown)
203	wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
204	w.FocusID(1)
205	c.applyAction(ActionMoveWindowUp)
206	wantBands(t, w, []WindowID{1}, []WindowID{2, 3}, []WindowID{4}, []WindowID{5})
207	wantFocus(t, c, "M", 1)
208}
209
210// A band left empty goes away.
211func TestCascadeMoveWindowDropsEmptyBand(t *testing.T) {
212	c, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
213	w.FocusID(2)
214	c.applyAction(ActionMoveWindowDown)
215	wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2})
216	w.FocusID(4)
217	c.applyAction(ActionMoveWindowUp) // At its offset in the band: first.
218	wantBands(t, w, []WindowID{4, 1, 3}, []WindowID{2})
219}
220
221// A maximized column keeps its band to itself: moving it, or moving a
222// column toward it, makes a new band; repeating the move passes it.
223func TestCascadeMoveWindowAndMaximizedBand(t *testing.T) {
224	for _, tc := range []struct {
225		name       string
226		n          int
227		max, focus WindowID
228		action     Action
229		want       [][]WindowID
230	}{
231		// After maximizing 2: {1} {2*} {3 4}.
232		{"maximized down", 4, 2, 2, ActionMoveWindowDown, [][]WindowID{{1}, {3, 4}, {2}}},
233		{"maximized up", 4, 2, 2, ActionMoveWindowUp, [][]WindowID{{2}, {1}, {3, 4}}},
234		// After maximizing 4: {1 2 3} {4*} {5}.
235		{"into maximized below", 5, 4, 3, ActionMoveWindowDown, [][]WindowID{{1, 2}, {3}, {4}, {5}}},
236		{"into maximized above", 5, 4, 5, ActionMoveWindowUp, [][]WindowID{{1, 2, 3}, {5}, {4}}},
237	} {
238		t.Run(tc.name, func(t *testing.T) {
239			c, w := cascadeMovesCore(t, tc.n, 3, false)
240			w.FocusID(tc.max)
241			w.ToggleFullWidth()
242			w.FocusID(tc.focus)
243			c.applyAction(tc.action)
244			wantBands(t, w, tc.want...)
245			wantFocus(t, c, "M", tc.focus)
246			if !w.Columns[w.columnOf(tc.max)].FullWidth {
247				t.Fatal("maximization lost")
248			}
249		})
250	}
251}
252
253// A fullscreen tile at its band edge leaves for the neighbor monitor and
254// stays fullscreen, as a tile there does.
255func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) {
256	c, w := cascadeMovesCore(t, 5, 3, true)
257	w.FocusID(3)
258	w.ToggleFullscreen()
259	c.applyAction(ActionFocusColumnRight)
260	if got := c.cur().name(); got != "R" {
261		t.Fatalf("focused screen %q, want R", got)
262	}
263	if id, _ := w.Focused(); w.fullscreen != 3 || id != 3 {
264		t.Fatalf("fullscreen %d, focused %d on M, want 3 and 3", w.fullscreen, id)
265	}
266}
267
268// From a native float over the columns, the first move leaves the float
269// on this monitor, even with the focused column at its band edge.
270func TestCascadeFloatAtBandEdgeStaysOnMonitor(t *testing.T) {
271	c, w := cascadeMovesCore(t, 5, 3, true)
272	w.FocusID(3)
273	w.AddFloating(9, 100, 100)
274	c.applyAction(ActionFocusColumnRight)
275	if got := c.cur().name(); got != "M" {
276		t.Fatalf("focused screen %q, want M", got)
277	}
278	if w.floatFocus {
279		t.Fatal("focus still on the float")
280	}
281}
282
283// A stacked window moves inside its column first, then the column changes
284// band.
285func TestCascadeMoveWindowStackedFirst(t *testing.T) {
286	c, w := cascadeMovesCore(t, 5, 3, false)
287	w.FocusID(5)
288	w.ConsumeOrExpel(-1) // bands {1 2 3} {4/5}
289	if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{4, 5}) {
290		t.Fatalf("stack %v, want [4 5]", got)
291	}
292	c.applyAction(ActionMoveWindowUp)
293	if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{5, 4}) {
294		t.Fatalf("stack %v, want [5 4]", got)
295	}
296	c.applyAction(ActionMoveWindowUp)
297	// The full band above cannot take it, so it passes it.
298	wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3})
299	wantFocus(t, c, "M", 5)
300}
301
302// A fullscreen window keeps its band.
303func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
304	c, w := cascadeMovesCore(t, 5, 3, false)
305	w.FocusID(4)
306	w.ToggleFullscreen()
307	c.applyAction(ActionMoveWindowUp)
308	wantColumns(t, w, 1, 2, 3, 4, 5)
309}
310
311// Scroll overflow has no bands: up/down stop at the column edge.
312func TestScrollMoveWindowStopsAtColumnEdge(t *testing.T) {
313	c, w := cascadeMovesCore(t, 2, 3, false)
314	w.Overflow = OverflowScroll
315	w.FocusID(2)
316	c.applyAction(ActionMoveWindowUp)
317	c.applyAction(ActionMoveWindowDown)
318	wantColumns(t, w, 1, 2)
319	wantFocus(t, c, "M", 2)
320}

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 25

Tags 10

↑↓ moveenter openesc close