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
55func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {
56	t.Helper()
57	if got := c.cur().name(); got != screen {
58		t.Fatalf("focused screen %q, want %q", got, screen)
59	}
60	if got, _ := c.cur().mon.Current().Focused(); got != id {
61		t.Fatalf("focused window %d, want %d", got, id)
62	}
63}
64
65// At the edge of its band, a cascade column goes to the neighbor monitor
66// on that side, and focus with it; bands {1 2 3} {4 5}.
67func TestCascadeBandEdgeMovesToNeighborMonitor(t *testing.T) {
68	for _, tc := range []struct {
69		name         string
70		from         WindowID
71		action       Action
72		screen       string
73		here, moved  []WindowID
74		focusedAfter WindowID
75	}{
76		{"move left from band start", 4, ActionMoveColumnLeft, "L", []WindowID{1, 2, 3, 5}, []WindowID{4}, 4},
77		{"move right from band end", 3, ActionMoveColumnRight, "R", []WindowID{1, 2, 4, 5}, []WindowID{3}, 3},
78		{"move right from last column", 5, ActionMoveColumnRight, "R", []WindowID{1, 2, 3, 4}, []WindowID{5}, 5},
79		{"focus left from band start", 4, ActionFocusColumnLeft, "L", []WindowID{1, 2, 3, 4, 5}, nil, 0},
80		{"focus right from band end", 3, ActionFocusColumnRight, "R", []WindowID{1, 2, 3, 4, 5}, nil, 0},
81	} {
82		t.Run(tc.name, func(t *testing.T) {
83			c, w := cascadeMovesCore(t, 5, 3, true)
84			w.FocusID(tc.from)
85			c.applyAction(tc.action)
86			wantColumns(t, w, tc.here...)
87			wantColumns(t, screenWorkspace(c, tc.screen), tc.moved...)
88			wantFocus(t, c, tc.screen, tc.focusedAfter)
89		})
90	}
91}
92
93// Inside a band, left/right stay on the screen.
94func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
95	c, w := cascadeMovesCore(t, 5, 3, true)
96	w.FocusID(5)
97	c.applyAction(ActionMoveColumnLeft)
98	wantColumns(t, w, 1, 2, 3, 5, 4)
99	wantFocus(t, c, "M", 5)
100	c.applyAction(ActionFocusColumnRight)
101	wantFocus(t, c, "M", 4)
102}
103
104// With no monitor on that side, left/right never cross into the neighbor
105// band: at the band edge they step the workspace (the first has none before
106// it; the last is the trailing empty one). up/down cross bands, and
107// move-column still crosses in column order.
108func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
109	c, w := cascadeMovesCore(t, 5, 3, false)
110	m := c.cur().mon
111	w.FocusID(4)
112	c.applyAction(ActionFocusColumnLeft)
113	wantFocus(t, c, "M", 4)
114	if m.Active != 0 {
115		t.Fatalf("active %d, want 0", m.Active)
116	}
117	w.FocusID(3)
118	c.applyAction(ActionFocusColumnRight)
119	if m.Active != 1 || m.Current() == w {
120		t.Fatalf("active %d, want the trailing empty workspace", m.Active)
121	}
122	m.Focus(0)
123	w.FocusID(4)
124	c.applyAction(ActionMoveColumnLeft)
125	wantColumns(t, w, 1, 2, 4, 3, 5)
126	wantFocus(t, c, "M", 4)
127	w.FocusID(4)
128	c.applyAction(ActionMoveColumnRight)
129	wantColumns(t, w, 1, 2, 3, 4, 5)
130	wantFocus(t, c, "M", 4)
131}
132
133// With one column per band, every column is at both edges of its band.
134func TestCascadeOneColumnBandsLeaveForMonitor(t *testing.T) {
135	c, w := cascadeMovesCore(t, 2, 1, true)
136	w.FocusID(2)
137	c.applyAction(ActionMoveColumnLeft)
138	wantColumns(t, w, 1)
139	wantColumns(t, screenWorkspace(c, "L"), 2)
140	wantFocus(t, c, "L", 2)
141}
142
143// Fixed overflow keeps its column order edge: a non-edge column swaps.
144func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) {
145	c, w := cascadeMovesCore(t, 2, 3, true)
146	w.Overflow = OverflowFixed
147	w.FocusID(2)
148	c.applyAction(ActionMoveColumnLeft)
149	wantColumns(t, w, 2, 1)
150	wantFocus(t, c, "M", 2)
151}
152
153// At the top or bottom of its column, move-window-up/down moves a cascade
154// column to the adjacent band, where focus-window-up/down goes; bands
155// {1 2 3} {4 5}.
156func TestCascadeMoveWindowCrossesBands(t *testing.T) {
157	c, w := cascadeMovesCore(t, 5, 3, false)
158	w.FocusID(5)
159	c.applyAction(ActionMoveWindowUp)
160	wantColumns(t, w, 1, 5, 3, 4, 2)
161	wantFocus(t, c, "M", 5)
162	c.applyAction(ActionMoveWindowDown)
163	wantColumns(t, w, 1, 2, 3, 4, 5)
164	wantFocus(t, c, "M", 5)
165}
166
167// The last band is partial: the move clamps to its last column, and going
168// back up keeps the new offset, as focus does.
169func TestCascadeMoveWindowPartialBand(t *testing.T) {
170	c, w := cascadeMovesCore(t, 5, 3, false)
171	w.FocusID(3)
172	c.applyAction(ActionMoveWindowDown)
173	wantColumns(t, w, 1, 2, 5, 4, 3)
174	c.applyAction(ActionMoveWindowUp)
175	wantColumns(t, w, 1, 3, 5, 4, 2)
176	wantFocus(t, c, "M", 3)
177}
178
179// No band past the first or last: nothing moves.
180func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) {
181	c, w := cascadeMovesCore(t, 5, 3, false)
182	w.FocusID(1)
183	c.applyAction(ActionMoveWindowUp)
184	wantFocus(t, c, "M", 1)
185	w.FocusID(4)
186	c.applyAction(ActionMoveWindowDown)
187	wantColumns(t, w, 1, 2, 3, 4, 5)
188	wantFocus(t, c, "M", 4)
189}
190
191// A maximized column keeps its band to itself when it moves: it swaps
192// with the column of the band above and stays maximized.
193func TestCascadeMoveWindowKeepsMaximization(t *testing.T) {
194	c, w := cascadeMovesCore(t, 5, 3, false)
195	w.FocusID(5)
196	w.ToggleFullWidth() // Bands {1 2 3} {4} {5}.
197	c.applyAction(ActionMoveWindowUp)
198	wantColumns(t, w, 1, 2, 3, 5, 4)
199	wantFocus(t, c, "M", 5)
200	if !w.Columns[3].FullWidth || w.band(3) != 1 || w.band(4) != 2 {
201		t.Fatalf("maximized %t, bands %d %d", w.Columns[3].FullWidth, w.band(3), w.band(4))
202	}
203}
204
205// A column moving to or from a maximized column's band passes the whole
206// adjacent band. Bands follow column order, so a maximized column leaving
207// lets the bands around it pack together: {1} and {3 4} join.
208// Repeating the move never leaves the column stuck.
209func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) {
210	for _, tc := range []struct {
211		name       string
212		max, focus WindowID
213		action     Action
214		want       []WindowID
215		band       int
216	}{
217		// Bands {1} {2*} {3 4}.
218		{"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1},
219		{"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0},
220		// Bands {1 2 3} {4*} {5}.
221		{"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2},
222		{"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1},
223	} {
224		t.Run(tc.name, func(t *testing.T) {
225			n := 5
226			if tc.max == 2 {
227				n = 4
228			}
229			c, w := cascadeMovesCore(t, n, 3, false)
230			w.FocusID(tc.max)
231			w.ToggleFullWidth()
232			w.FocusID(tc.focus)
233			c.applyAction(tc.action)
234			wantColumns(t, w, tc.want...)
235			wantFocus(t, c, "M", tc.focus)
236			if got := w.band(w.Focus); got != tc.band {
237				t.Fatalf("band %d, want %d", got, tc.band)
238			}
239			if !w.Columns[w.columnOf(tc.max)].FullWidth {
240				t.Fatal("maximization lost")
241			}
242		})
243	}
244}
245
246// A fullscreen tile at its band edge leaves for the neighbor monitor and
247// stays fullscreen, as a tile there does.
248func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) {
249	c, w := cascadeMovesCore(t, 5, 3, true)
250	w.FocusID(3)
251	w.ToggleFullscreen()
252	c.applyAction(ActionFocusColumnRight)
253	if got := c.cur().name(); got != "R" {
254		t.Fatalf("focused screen %q, want R", got)
255	}
256	if id, _ := w.Focused(); w.fullscreen != 3 || id != 3 {
257		t.Fatalf("fullscreen %d, focused %d on M, want 3 and 3", w.fullscreen, id)
258	}
259}
260
261// From a native float over the columns, the first move leaves the float
262// on this monitor, even with the focused column at its band edge.
263func TestCascadeFloatAtBandEdgeStaysOnMonitor(t *testing.T) {
264	c, w := cascadeMovesCore(t, 5, 3, true)
265	w.FocusID(3)
266	w.AddFloating(9, 100, 100)
267	c.applyAction(ActionFocusColumnRight)
268	if got := c.cur().name(); got != "M" {
269		t.Fatalf("focused screen %q, want M", got)
270	}
271	if w.floatFocus {
272		t.Fatal("focus still on the float")
273	}
274}
275
276// A stacked window moves inside its column first, then the column changes
277// band.
278func TestCascadeMoveWindowStackedFirst(t *testing.T) {
279	c, w := cascadeMovesCore(t, 5, 3, false)
280	w.FocusID(5)
281	w.ConsumeOrExpel(-1) // bands {1 2 3} {4/5}
282	if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{4, 5}) {
283		t.Fatalf("stack %v, want [4 5]", got)
284	}
285	c.applyAction(ActionMoveWindowUp)
286	if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{5, 4}) {
287		t.Fatalf("stack %v, want [5 4]", got)
288	}
289	c.applyAction(ActionMoveWindowUp)
290	wantColumns(t, w, 5, 2, 3, 1)
291	wantFocus(t, c, "M", 5)
292}
293
294// A fullscreen window keeps its band.
295func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
296	c, w := cascadeMovesCore(t, 5, 3, false)
297	w.FocusID(4)
298	w.ToggleFullscreen()
299	c.applyAction(ActionMoveWindowUp)
300	wantColumns(t, w, 1, 2, 3, 4, 5)
301}
302
303// Scroll overflow has no bands: up/down stop at the column edge.
304func TestScrollMoveWindowStopsAtColumnEdge(t *testing.T) {
305	c, w := cascadeMovesCore(t, 2, 3, false)
306	w.Overflow = OverflowScroll
307	w.FocusID(2)
308	c.applyAction(ActionMoveWindowUp)
309	c.applyAction(ActionMoveWindowDown)
310	wantColumns(t, w, 1, 2)
311	wantFocus(t, c, "M", 2)
312}

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