feat/hints a471fa4

layout_test.go RawHistoryFolder

1package core
2
3import (
4	"math"
5	"reflect"
6	"testing"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11func workspace() *Workspace {
12	w := &Workspace{}
13	w.SetOutput(100, 81)
14	w.SetGaps(5)
15	w.SetMaxColumns(2)
16	return w
17}
18
19// Columns of a lane share the usable width by their cells; unset columns
20// share what the others leave.
21func TestLaneWidths(t *testing.T) {
22	cells := func(w *Workspace, c ...int) []int {
23		for i, n := range c {
24			w.Columns[i].Cells = n
25		}
26		var got []int
27		for i := range w.Columns {
28			got = append(got, w.cellWidth(i))
29		}
30		return got
31	}
32	for _, overflow := range []Overflow{OverflowCascade, OverflowFixed} {
33		t.Run(string(overflow), func(t *testing.T) {
34			mk := func(n int) *Workspace {
35				w := &Workspace{MaxColumns: 4, Overflow: overflow, Gaps: 8}
36				w.SetOutput(1000, 500)
37				for id := 1; id <= n; id++ {
38					w.AddWindow(WindowID(id))
39				}
40				return w
41			}
42			avail := 1000 - 3*8
43			if got, want := cells(mk(2), 1, 3), []int{avail / 4, avail * 3 / 4}; !reflect.DeepEqual(got, want) {
44				t.Errorf("1,3: got %v want %v", got, want)
45			}
46			// Auto columns share what the set ones leave.
47			w := mk(3)
48			avail = 1000 - 4*8
49			if got, want := cells(w, 2, 0, 0), []int{avail * 2 / 4, avail / 4, avail / 4}; !reflect.DeepEqual(got, want) {
50				t.Errorf("2,auto,auto: got %v want %v", got, want)
51			}
52			// No room for them: an auto column weighs one cell.
53			if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {
54				t.Errorf("3,auto,auto: got %v want %v", got, want)
55			}
56			// All auto: the equal share of n columns, pixel for pixel.
57			for n := 1; n <= 4; n++ {
58				w := mk(n)
59				for i := range w.Columns {
60					if got := w.cellWidth(i); got != w.equalShare(n) {
61						t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))
62					}
63				}
64			}
65			// Positions follow the widths.
66			w = mk(2)
67			cells(w, 1, 3)
68			r := w.columnRects()
69			if r[0].X != 8 || r[1].X != 8+r[0].W+8 {
70				t.Errorf("rects %v", r)
71			}
72		})
73	}
74	t.Run("scroll", func(t *testing.T) {
75		w := &Workspace{MaxColumns: 4, Gaps: 8}
76		w.SetOutput(1000, 500)
77		w.AddWindow(1)
78		w.AddWindow(2)
79		w.Columns[1].Cells = 2
80		if got, want := w.columnWidth(1), 2*w.equalShare(4)+8; got != want {
81			t.Errorf("2 cells: %d want %d", got, want)
82		}
83		if got, want := w.columnWidth(0), w.equalShare(2); got != want {
84			t.Errorf("auto: %d want %d", got, want)
85		}
86		w.Columns[1].Cells = 9
87		if got, want := w.columnWidth(1), 4*w.equalShare(4)+3*8; got != want {
88			t.Errorf("above max-columns: %d want %d", got, want)
89		}
90	})
91	t.Run("fixed past max-columns ignores cells", func(t *testing.T) {
92		w := fixedWorkspace(2, 0, 3)
93		before := w.columnRects()
94		w.Columns[0].Cells = 1
95		w.Columns[1].Cells = 3
96		if after := w.columnRects(); !reflect.DeepEqual(before, after) {
97			t.Errorf("%v != %v", after, before)
98		}
99	})
100}
101func TestOperations(t *testing.T) {
102	w := workspace()
103	w.FocusColumn(1)
104	w.FocusWindow(-1)
105	w.MoveColumn(1)
106	w.GrowColumn(true)
107	w.ToggleFullscreen()
108	w.RemoveWindow(1)
109	if _, ok := w.Focused(); ok {
110		t.Fatal("empty focus")
111	}
112	w.AddWindow(1)
113	w.AddWindow(2)
114	w.AddWindow(3)
115	// Three auto columns, two visible: each (100-3*5)/2 = 42 wide.
116	if w.Focus != 2 || w.View != 46 || w.columnWidth(0) != 42 {
117		t.Fatalf("scroll: %+v", w)
118	}
119	w.FocusColumn(-1)
120	if w.View != 46 {
121		t.Fatal(w.View)
122	}
123	w.FocusColumn(-1)
124	if w.View != 0 {
125		t.Fatal(w.View)
126	}
127	w.MoveColumn(1)
128	if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {
129		t.Fatal(w.Columns)
130	}
131	w.RemoveWindow(1)
132	if id, _ := w.Focused(); id != 2 {
133		t.Fatal(id)
134	}
135	w.RemoveWindow(3)
136	w.RemoveWindow(2)
137	if len(w.Columns) != 0 {
138		t.Fatal(w.Columns)
139	}
140}
141func TestStackAndLayout(t *testing.T) {
142	w := workspace()
143	w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
144	w.RemoveWindow(2)
145	if id, _ := w.Focused(); id != 1 {
146		t.Fatal(id)
147	}
148	w.FocusWindow(1)
149	if id, _ := w.Focused(); id != 3 {
150		t.Fatal(id)
151	}
152	w.FocusWindow(-1)
153	p := w.Layout()
154	if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop {
155		t.Fatal(p)
156	}
157	w.ToggleFullscreen()
158	if p = w.Layout(); !p[0].Fullscreen || p[0].Rect != (Rect{X: 0, Y: 0, W: 100, H: 81}) || !p[1].Hidden || p[1].Rect != (Rect{}) {
159		t.Fatal(p)
160	}
161	w.ToggleFullscreen()
162	if w.Layout()[0].Fullscreen {
163		t.Fatal("toggle")
164	}
165	w.SetUsable(Rect{X: 10, Y: 10, W: 60, H: 50})
166	w.SetGaps(2)
167	if w.Layout()[0].Rect.X != 12 {
168		t.Fatal(w.Layout())
169	}
170	w.SetOutput(120, 90)
171	if w.Usable.W != 60 {
172		t.Fatal(w.Usable)
173	}
174}
175func TestMaximizeColumnScroll(t *testing.T) {
176	w := workspace()
177	for id := WindowID(1); id <= 3; id++ {
178		w.AddWindow(id)
179	}
180	w.Columns[1].Cells = 2
181	w.FocusID(2)
182	w.Apply(ActionMaximizeColumn)
183	if !w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {
184		t.Fatal(w.Columns, w.Layout())
185	}
186	if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen {
187		t.Fatal(w.Layout())
188	}
189	w.Apply(ActionMaximizeColumn)
190	if w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect.W != 89 {
191		t.Fatal(w.Columns, w.Layout())
192	}
193}
194
195func TestMaximizeColumnFixed(t *testing.T) {
196	w := workspace()
197	w.Overflow = OverflowFixed
198	for id := WindowID(1); id <= 3; id++ {
199		w.AddWindow(id)
200	}
201	w.FocusID(2)
202	w.Apply(ActionMaximizeColumn)
203	p := w.Layout()
204	if p[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) || p[1].Fullscreen || !p[0].Hidden || !p[2].Hidden {
205		t.Fatal(p)
206	}
207	w.FocusColumn(1)
208	p = w.Layout()
209	if w.Columns[1].FullWidth || p[2].Hidden || p[0].Hidden || p[1].Hidden {
210		t.Fatal(w.Columns, p)
211	}
212}
213
214func TestMaximizeColumnFixedAddWindow(t *testing.T) {
215	w := workspace()
216	w.Overflow = OverflowFixed
217	w.AddWindow(1)
218	w.AddWindow(2)
219	w.ToggleFullWidth()
220	w.AddWindow(3)
221	p := w.Layout()
222	if w.Columns[1].FullWidth || p[0].Hidden || p[1].Hidden || p[2].Hidden || p[2].Rect.W == 0 {
223		t.Fatal(w.Columns, p)
224	}
225}
226
227func TestCellsAndActions(t *testing.T) {
228	w := workspace()
229	w.AddWindow(1)
230	w.AddWindow(2)
231	// Scroll overflow: auto is 1 cell of 2, then 2 (the whole width), then auto.
232	w.Apply(ActionCycleColumnWidth)
233	if w.Columns[1].Cells != 2 {
234		t.Fatal(w.Columns)
235	}
236	w.GrowColumn(true)
237	if w.Columns[1].Cells != 0 {
238		t.Fatal(w.Columns)
239	}
240	w.RemoveWindow(2)
241	for _, tc := range []struct {
242		a    Action
243		want Effect
244	}{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Commands: [][]string{{"fuzzel", "--prompt", "x"}}}}, {"spawn a -x; ; b", Effect{Spawn: true, Commands: [][]string{{"a", "-x"}, {"b"}}}}, {"spawn   ", Effect{}}, {"spawn ; ", Effect{}}} {
245		if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) {
246			t.Errorf("%s: %+v", tc.a, got)
247		}
248	}
249}
250func FuzzWorkspaceOps(f *testing.F) {
251	f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
252	f.Fuzz(func(t *testing.T, ops []byte) {
253		w := workspace()
254		next := WindowID(1)
255		for _, op := range ops {
256			switch op % 18 {
257			case 0:
258				w.AddWindow(next)
259				next++
260			case 1:
261				if id, ok := w.Focused(); ok {
262					w.RemoveWindow(id)
263				}
264			case 2:
265				w.FocusColumn(-1)
266			case 3:
267				w.FocusColumn(1)
268			case 4:
269				w.FocusWindow(-1)
270			case 5:
271				w.FocusWindow(1)
272			case 6:
273				w.MoveColumn(-1)
274			case 7:
275				w.MoveColumn(1)
276			case 8:
277				w.GrowColumn(op%2 == 0)
278			case 9:
279				w.ToggleFullscreen()
280			case 10:
281				w.SetGaps(int(op) % 10)
282			case 11:
283				w.SetOutput(int(op)-50, int(op)-80)
284			case 12:
285				w.SetUsable(Rect{X: int(op) % 20, Y: int(op) % 20, W: int(op) - 50, H: int(op) - 80})
286			case 13:
287				w.SetGaps(int(op) * 2)
288			case 14:
289				if len(w.Columns) > 1 {
290					stackFocused(w)
291				}
292			case 15:
293				if len(w.Columns) > 0 {
294					w.Columns[w.Focus].Cells = math.MaxInt
295					w.scroll()
296				}
297			case 16:
298				w.SetOutput(0, 0)
299			case 17:
300				w.SetUsable(Rect{W: math.MaxInt, H: math.MaxInt})
301			}
302			seen := map[WindowID]bool{}
303			for _, c := range w.Columns {
304				if len(c.Windows) == 0 || c.Focus < 0 || c.Focus >= len(c.Windows) {
305					t.Fatal("invalid column")
306				}
307				for _, id := range c.Windows {
308					if seen[id] {
309						t.Fatal("duplicate")
310					}
311					seen[id] = true
312				}
313			}
314			if len(w.Columns) > 0 {
315				if w.Focus < 0 || w.Focus >= len(w.Columns) {
316					t.Fatal("focus")
317				}
318				left := w.columnX(w.Focus) - w.View
319				width := w.columnWidth(w.Focus)
320				minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()
321				if w.fullscreenColumn(w.Focus) {
322					minX, maxX = 0, w.Output.W
323				}
324				if width <= maxX-minX && (left < minX || left+width > maxX) {
325					t.Fatal("scroll")
326				}
327			}
328			placements := w.Layout()
329			if len(placements) != len(seen) {
330				t.Fatal("count")
331			}
332			fullCount := 0
333			for _, p := range placements {
334				if p.Fullscreen {
335					fullCount++
336				}
337				r := p.Rect
338				if r.W < 0 || r.H < 0 || r.Y < 0 || r.Y > w.Output.H || r.H > w.Output.H-r.Y {
339					t.Fatalf("bounds: %+v output %+v", p, w.Output)
340				}
341				if p.Hidden && r != (Rect{}) {
342					t.Fatal("hidden rect")
343				}
344				if !p.Hidden && !p.Fullscreen && (r.Y < w.Usable.Y || r.Y > w.Usable.Y+w.Usable.H || r.H > w.Usable.Y+w.Usable.H-r.Y) {
345					t.Fatalf("usable vertical bounds: %+v usable %+v", p, w.Usable)
346				}
347				if !seen[p.ID] {
348					t.Fatal("missing")
349				}
350				delete(seen, p.ID)
351			}
352			if fullCount > 1 {
353				t.Fatal("multiple fullscreen")
354			}
355		}
356	})
357}
358
359// stackFocused moves the next column's window into the focused column.
360func stackFocused(w *Workspace) {
361	i := w.Focus
362	j := (i + 1) % len(w.Columns)
363	id := w.Columns[j].Windows[0]
364	w.Columns[i].Windows = append(w.Columns[i].Windows, id)
365	w.Columns[j].Windows = w.Columns[j].Windows[1:]
366	if len(w.Columns[j].Windows) == 0 {
367		w.Columns = append(w.Columns[:j], w.Columns[j+1:]...)
368		if j < i {
369			w.Focus--
370		}
371	} else if w.Columns[j].Focus >= len(w.Columns[j].Windows) {
372		w.Columns[j].Focus = len(w.Columns[j].Windows) - 1
373	}
374	w.scroll()
375}
376
377func TestLargeWidthAndBounds(t *testing.T) {
378	w := workspace()
379	w.AddWindow(1)
380	w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
381	w.SetGaps(math.MaxInt)
382	for _, p := range w.Layout() {
383		if p.Rect.W != 0 || p.Rect.H < 0 {
384			t.Fatal(p)
385		}
386	}
387	w.SetOutput(-1, -1)
388	if w.Output.W != 0 || w.Output.H != 0 {
389		t.Fatal(w.Output)
390	}
391}
392func TestUnchangedUsablePreservesView(t *testing.T) {
393	w := workspace()
394	w.AddWindow(1)
395	w.AddWindow(2)
396	w.AddWindow(3)
397	before := w.View
398	if before == 0 {
399		t.Fatal("expected a scrolled workspace")
400	}
401	w.SetUsable(w.Usable)
402	if w.View != before {
403		t.Fatalf("View changed from %d to %d", before, w.View)
404	}
405}
406
407func TestFullscreenPersistsAndScrolls(t *testing.T) {
408	w := workspace()
409	w.AddWindow(1)
410	w.AddWindow(2)
411	w.FocusColumn(-1)
412	w.ToggleFullscreen()
413	if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X != 0 || p.Rect.W != 100 {
414		t.Fatal(p)
415	}
416	w.FocusColumn(1)
417	if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X >= 0 {
418		t.Fatal(p)
419	}
420	w.ToggleFullscreen()
421	if w.Layout()[0].Fullscreen || !w.Layout()[1].Fullscreen {
422		t.Fatal(w.Layout())
423	}
424	w.RemoveWindow(2)
425	if w.Layout()[0].Fullscreen {
426		t.Fatal(w.Layout())
427	}
428}
429
430func TestSetFullscreenStackDeactivation(t *testing.T) {
431	w := workspace()
432	w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}}
433	// The covering fullscreen window is the only one drawn, so it has the
434	// focus; window 1 gets it back when 2 leaves fullscreen.
435	w.SetFullscreen(2, true)
436	p := w.Layout()
437	if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused || p[0].Focused {
438		t.Fatal(p)
439	}
440	w.SetFullscreen(2, false)
441	if p := w.Layout(); p[0].Hidden || !p[0].Focused {
442		t.Fatal(p)
443	}
444}
445
446// Fixed overflow keeps every window on screen: past max-columns, new columns
447// split the newest one, alternating top/bottom and left/right.
448func TestFixedOverflowSpiral(t *testing.T) {
449	w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
450	w.SetOutput(100, 80)
451	for id := WindowID(1); id <= 5; id++ {
452		w.AddWindow(id)
453	}
454	got := map[WindowID]Rect{}
455	for _, p := range w.Layout() {
456		got[p.ID] = p.Rect
457	}
458	want := map[WindowID]Rect{
459		1: {X: 0, Y: 0, W: 50, H: 80},
460		2: {X: 50, Y: 0, W: 50, H: 40},  // top half of slot 2
461		3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half
462		4: {X: 75, Y: 40, W: 25, H: 20},
463		5: {X: 75, Y: 60, W: 25, H: 20},
464	}
465	if !reflect.DeepEqual(got, want) {
466		t.Fatalf("got %v\nwant %v", got, want)
467	}
468	if w.View != 0 {
469		t.Fatal("fixed overflow scrolled", w.View)
470	}
471	// Up to max-columns it is the plain equal split.
472	w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
473	w2.SetOutput(100, 80)
474	w2.AddWindow(1)
475	w2.AddWindow(2)
476	if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) {
477		t.Fatal(p)
478	}
479}
480
481// In fixed overflow, cycle-column-width grows the focused column by one
482// cell, taking it from another column of the row.
483func TestFixedGrowColumn(t *testing.T) {
484	rects := func(w *Workspace) map[WindowID]Rect {
485		got := map[WindowID]Rect{}
486		for id, p := range placements(w) {
487			got[id] = p.Rect
488		}
489		return got
490	}
491	check := func(t *testing.T, w *Workspace, want map[WindowID]Rect) {
492		t.Helper()
493		if got := rects(w); !reflect.DeepEqual(got, want) {
494			t.Fatalf("got %v\nwant %v", got, want)
495		}
496	}
497	t.Run("free cell", func(t *testing.T) {
498		w := fixedWorkspace(4, 0, 2)
499		w.FocusID(1)
500		w.GrowColumn(true)
501		check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})
502		w.GrowColumn(true)
503		check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
504	})
505	t.Run("middle column", func(t *testing.T) {
506		w := fixedWorkspace(4, 0, 3)
507		w.FocusID(2)
508		w.GrowColumn(true)
509		// The remainder cell was the step.
510		check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
511		// No donor is left: back to equal shares.
512		w.GrowColumn(true)
513		check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})
514	})
515	t.Run("with gaps", func(t *testing.T) {
516		w := fixedWorkspace(4, 4, 2)
517		w.FocusID(1)
518		w.GrowColumn(true)
519		// Three cells and one of the 88 px left by the three gaps.
520		check(t, w, map[WindowID]Rect{
521			1: {X: 4, Y: 4, W: 66, H: 72},
522			2: {X: 74, Y: 4, W: 22, H: 72},
523		})
524	})
525	t.Run("stays on focus change and new windows", func(t *testing.T) {
526		w := fixedWorkspace(4, 0, 2)
527		w.FocusID(1)
528		w.GrowColumn(true)
529		w.FocusColumn(1)
530		w.AddWindow(3)
531		// 3, 1 and an auto column that weighs one cell.
532		check(t, w, map[WindowID]Rect{1: {W: 60, H: 80}, 2: {X: 60, W: 20, H: 80}, 3: {X: 80, W: 20, H: 80}})
533	})
534	t.Run("the wider column gives a cell to the focused one", func(t *testing.T) {
535		w := fixedWorkspace(4, 0, 2)
536		w.FocusID(1)
537		w.GrowColumn(true)
538		w.FocusID(2)
539		w.GrowColumn(true)
540		check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
541	})
542	t.Run("closing it shares again", func(t *testing.T) {
543		w := fixedWorkspace(4, 0, 3)
544		w.FocusID(1)
545		w.GrowColumn(true)
546		w.RemoveWindow(1)
547		check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}})
548	})
549	t.Run("no-op when it cannot grow", func(t *testing.T) {
550		for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(2, 0, 2), fixedWorkspace(3, 0, 5)} {
551			before := rects(w)
552			w.GrowColumn(true)
553			check(t, w, before)
554		}
555	})
556	t.Run("stays on screen when gaps do not fit", func(t *testing.T) {
557		for _, w := range []*Workspace{fixedWorkspace(4, 4, 2), fixedWorkspace(4, 4, 3)} {
558			w.SetOutput(12, 80)
559			w.FocusID(2)
560			w.GrowColumn(true)
561			for id, p := range placements(w) {
562				r := p.Rect
563				if r.X < 0 || r.Y < 0 || r.W < 0 || r.H < 0 || r.X+r.W > w.Output.W || r.Y+r.H > w.Output.H {
564					t.Fatal(id, r)
565				}
566			}
567		}
568	})
569	t.Run("maximize wins and gives it back", func(t *testing.T) {
570		w := fixedWorkspace(4, 0, 2)
571		w.FocusID(1)
572		w.GrowColumn(true)
573		w.ToggleFullWidth()
574		if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden {
575			t.Fatal(p)
576		}
577		w.ToggleFullWidth()
578		check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})
579	})
580}
581
582// Up and down cross the columns the spiral stacks.
583func TestFixedFocusStackedColumns(t *testing.T) {
584	focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id }
585	t.Run("spiral", func(t *testing.T) {
586		// 1 left, 2 top right, 3 bottom left, 4 over 5 bottom right.
587		w := fixedWorkspace(2, 4, 5)
588		for _, want := range []WindowID{4, 2} {
589			if !w.FocusWindow(-1) || focused(w) != want {
590				t.Fatal(focused(w), want)
591			}
592		}
593		if w.FocusWindow(-1) {
594			t.Fatal("moved past the top", focused(w))
595		}
596		w.FocusID(1)
597		if w.FocusWindow(1) {
598			t.Fatal("full height column moved", focused(w))
599		}
600	})
601	t.Run("left and right follow the screen", func(t *testing.T) {
602		// 1 left, 2 top right, 3 bottom left, 4 over 5 bottom right.
603		w := fixedWorkspace(2, 4, 5)
604		for _, step := range []struct {
605			dir  int
606			want WindowID
607		}{{-1, 3}, {-1, 1}, {1, 2}} {
608			w.FocusColumn(step.dir)
609			if focused(w) != step.want {
610				t.Fatal(focused(w), step.want)
611			}
612		}
613		w.FocusID(3)
614		w.FocusColumn(1)
615		if focused(w) != 4 {
616			t.Fatal(focused(w))
617		}
618		if w.FocusID(2); w.columnToward(1) >= 0 {
619			t.Fatal("right edge has a neighbor")
620		}
621	})
622	t.Run("a row of wide and narrow columns", func(t *testing.T) {
623		// 1 wide; 2, 3 and 4 beside it in one row.
624		w := fixedWorkspace(4, 0, 4)
625		w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells, w.Columns[3].Cells = 2, 1, 0, 0
626		w.FocusID(2)
627		if w.FocusWindow(1) || w.FocusWindow(-1) || focused(w) != 2 {
628			t.Fatal("a single row has no window above or below", focused(w))
629		}
630		w.FocusColumn(1)
631		if focused(w) != 3 {
632			t.Fatal(focused(w))
633		}
634		w.FocusColumn(-1)
635		w.FocusColumn(-1)
636		if focused(w) != 1 {
637			t.Fatal(focused(w))
638		}
639	})
640	t.Run("lands on the nearest window of a stacked column", func(t *testing.T) {
641		// 1 left; 2 top right holds two windows; 3 bottom right.
642		w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
643		w.SetOutput(100, 80)
644		w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 5}}, {Windows: []WindowID{3}}}
645		w.Focus = 2
646		if !w.FocusWindow(-1) || focused(w) != 5 {
647			t.Fatal(focused(w))
648		}
649	})
650}
651
652func fixedWorkspace(maxCols, gaps, n int) *Workspace {
653	w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
654	w.SetOutput(100, 80)
655	for id := 1; id <= n; id++ {
656		w.AddWindow(WindowID(id))
657	}
658	return w
659}
660
661func placements(w *Workspace) map[WindowID]Placement {
662	got := map[WindowID]Placement{}
663	for _, p := range w.Layout() {
664		got[p.ID] = p
665	}
666	return got
667}
668
669func TestFixedOverflowEdgeCases(t *testing.T) {
670	t.Run("new windows go last whatever the focus", func(t *testing.T) {
671		w := fixedWorkspace(2, 0, 3)
672		w.FocusID(1)
673		w.AddWindow(4)
674		if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 {
675			t.Fatal(got)
676		}
677	})
678	for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot
679		t.Run("fullscreen covers the output", func(t *testing.T) {
680			w := fixedWorkspace(2, 4, 4)
681			w.FocusID(col)
682			w.ToggleFullscreen()
683			for id, p := range placements(w) {
684				if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) {
685					t.Fatal(p)
686				}
687				if id != col && !p.Hidden {
688					t.Fatalf("window %d visible over fullscreen: %+v", id, p)
689				}
690			}
691		})
692	}
693	t.Run("a full row does not grow", func(t *testing.T) {
694		w := fixedWorkspace(2, 0, 2)
695		w.GrowColumn(true)
696		w.FocusColumn(-1)
697		w.GrowColumn(true)
698		for id, p := range placements(w) {
699			if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
700				t.Fatal(id, p)
701			}
702		}
703	})
704	t.Run("gaps between spiral halves", func(t *testing.T) {
705		got := placements(fixedWorkspace(1, 4, 2))
706		if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
707			t.Fatal(got[1].Rect, got[2].Rect)
708		}
709		if got[1].Neighbors == 0 || got[2].Neighbors == 0 {
710			t.Fatal("spiral windows must keep borders to show focus")
711		}
712	})
713}
714
715// Activating a window hidden by another's fullscreen shows it: the
716// fullscreen is exclusive, so it is left, even for a float.
717func TestActivateLeavesFullscreen(t *testing.T) {
718	for _, tc := range []struct {
719		name  string
720		setup func(w *Workspace)
721		keep  bool
722	}{
723		{"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false},
724		{"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false},
725		{"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, false},
726	} {
727		t.Run(tc.name, func(t *testing.T) {
728			w := &Workspace{}
729			w.SetOutput(100, 80)
730			w.AddWindow(2)
731			tc.setup(w)
732			w.FocusID(2)
733			w.SetFullscreen(2, true)
734			w.Activate(3)
735			for _, p := range w.Layout() {
736				if p.ID == 3 && (p.Hidden || !p.Focused) {
737					t.Fatalf("3 not shown and focused: %+v", p)
738				}
739				if p.ID == 2 && p.Fullscreen != tc.keep {
740					t.Fatalf("fullscreen of 2 = %v", p.Fullscreen)
741				}
742			}
743		})
744	}
745}

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