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

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 23

Tags 10

↑↓ moveenter openesc close