v0.1.0 d9218d6

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}
18func TestWidths(t *testing.T) {
19	for _, tc := range []struct {
20		s      string
21		ok     bool
22		pixels int
23	}{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {
24		v, e := ParseWidth(tc.s)
25		if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
26			t.Errorf("%q: %v %v", tc.s, v, e)
27		}
28	}
29	if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {
30		t.Fatal(n)
31	}
32	if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {
33		t.Fatal(n)
34	}
35}
36func TestOperations(t *testing.T) {
37	w := workspace()
38	w.FocusColumn(1)
39	w.FocusWindow(-1)
40	w.MoveColumn(1)
41	w.CycleWidth()
42	w.ToggleFullscreen()
43	w.RemoveWindow(1)
44	if _, ok := w.Focused(); ok {
45		t.Fatal("empty focus")
46	}
47	w.AddWindow(1)
48	w.AddWindow(2)
49	w.AddWindow(3)
50	// Three auto columns, two visible: each (100-3*5)/2 = 42 wide.
51	if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 {
52		t.Fatalf("scroll: %+v", w)
53	}
54	w.FocusColumn(-1)
55	if w.ViewX != 46 {
56		t.Fatal(w.ViewX)
57	}
58	w.FocusColumn(-1)
59	if w.ViewX != 0 {
60		t.Fatal(w.ViewX)
61	}
62	w.MoveColumn(1)
63	if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {
64		t.Fatal(w.Columns)
65	}
66	w.RemoveWindow(1)
67	if id, _ := w.Focused(); id != 2 {
68		t.Fatal(id)
69	}
70	w.RemoveWindow(3)
71	w.RemoveWindow(2)
72	if len(w.Columns) != 0 {
73		t.Fatal(w.Columns)
74	}
75}
76func TestStackAndLayout(t *testing.T) {
77	w := workspace()
78	w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
79	w.RemoveWindow(2)
80	if id, _ := w.Focused(); id != 1 {
81		t.Fatal(id)
82	}
83	w.FocusWindow(1)
84	if id, _ := w.Focused(); id != 3 {
85		t.Fatal(id)
86	}
87	w.FocusWindow(-1)
88	p := w.Layout()
89	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 {
90		t.Fatal(p)
91	}
92	w.ToggleFullscreen()
93	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{}) {
94		t.Fatal(p)
95	}
96	w.ToggleFullscreen()
97	if w.Layout()[0].Fullscreen {
98		t.Fatal("toggle")
99	}
100	w.SetUsable(Rect{X: 10, Y: 10, W: 60, H: 50})
101	w.SetGaps(2)
102	if w.Layout()[0].Rect.X != 12 {
103		t.Fatal(w.Layout())
104	}
105	w.SetOutput(120, 90)
106	if w.Usable.W != 60 {
107		t.Fatal(w.Usable)
108	}
109}
110func TestPresetsAndActions(t *testing.T) {
111	w := workspace()
112	w.AddWindow(1)
113	p := []Width{{Pixels: 30}, {Pixels: 40}}
114	w.SetPresets(p)
115	p[0].Pixels = 99
116	w.AddWindow(2)
117	w.Apply(ActionCycleColumnWidth)
118	if w.Columns[1].Width.Pixels != 30 {
119		t.Fatal(w.Columns)
120	}
121	w.CycleWidth()
122	if w.Columns[1].Width.Pixels != 40 {
123		t.Fatal(w.Columns)
124	}
125	// After the last preset, back to the automatic share.
126	w.CycleWidth()
127	if w.Columns[1].Width != (Width{}) {
128		t.Fatal(w.Columns)
129	}
130	w.RemoveWindow(2)
131	for _, tc := range []struct {
132		a    Action
133		want Effect
134	}{{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, Argv: []string{"fuzzel", "--prompt", "x"}}}, {"spawn   ", Effect{}}} {
135		if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) {
136			t.Errorf("%s: %+v", tc.a, got)
137		}
138	}
139	w.SetPresets(nil)
140	old := w.Columns[0].Width
141	w.CycleWidth()
142	if w.Columns[0].Width != old {
143		t.Fatal("empty presets")
144	}
145}
146func FuzzWorkspaceOps(f *testing.F) {
147	f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
148	f.Fuzz(func(t *testing.T, ops []byte) {
149		w := workspace()
150		w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
151		next := WindowID(1)
152		for _, op := range ops {
153			switch op % 18 {
154			case 0:
155				w.AddWindow(next)
156				next++
157			case 1:
158				if id, ok := w.Focused(); ok {
159					w.RemoveWindow(id)
160				}
161			case 2:
162				w.FocusColumn(-1)
163			case 3:
164				w.FocusColumn(1)
165			case 4:
166				w.FocusWindow(-1)
167			case 5:
168				w.FocusWindow(1)
169			case 6:
170				w.MoveColumn(-1)
171			case 7:
172				w.MoveColumn(1)
173			case 8:
174				w.CycleWidth()
175			case 9:
176				w.ToggleFullscreen()
177			case 10:
178				w.SetGaps(int(op) % 10)
179			case 11:
180				w.SetOutput(int(op)-50, int(op)-80)
181			case 12:
182				w.SetUsable(Rect{X: int(op) % 20, Y: int(op) % 20, W: int(op) - 50, H: int(op) - 80})
183			case 13:
184				w.SetGaps(int(op) * 2)
185			case 14:
186				if len(w.Columns) > 1 {
187					stackFocused(w)
188				}
189			case 15:
190				if len(w.Columns) > 0 {
191					w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}
192					w.scroll()
193				}
194			case 16:
195				w.SetOutput(0, 0)
196			case 17:
197				w.SetUsable(Rect{W: math.MaxInt, H: math.MaxInt})
198			}
199			seen := map[WindowID]bool{}
200			for _, c := range w.Columns {
201				if len(c.Windows) == 0 || c.Focus < 0 || c.Focus >= len(c.Windows) {
202					t.Fatal("invalid column")
203				}
204				for _, id := range c.Windows {
205					if seen[id] {
206						t.Fatal("duplicate")
207					}
208					seen[id] = true
209				}
210			}
211			if len(w.Columns) > 0 {
212				if w.Focus < 0 || w.Focus >= len(w.Columns) {
213					t.Fatal("focus")
214				}
215				left := w.columnX(w.Focus) - w.ViewX
216				width := w.columnWidth(w.Focus)
217				minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()
218				if w.fullscreenColumn(w.Focus) {
219					minX, maxX = 0, w.Output.W
220				}
221				if width <= maxX-minX && (left < minX || left+width > maxX) {
222					t.Fatal("scroll")
223				}
224			}
225			placements := w.Layout()
226			if len(placements) != len(seen) {
227				t.Fatal("count")
228			}
229			fullCount := 0
230			for _, p := range placements {
231				if p.Fullscreen {
232					fullCount++
233				}
234				r := p.Rect
235				if r.W < 0 || r.H < 0 || r.Y < 0 || r.Y > w.Output.H || r.H > w.Output.H-r.Y {
236					t.Fatalf("bounds: %+v output %+v", p, w.Output)
237				}
238				if p.Hidden && r != (Rect{}) {
239					t.Fatal("hidden rect")
240				}
241				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) {
242					t.Fatalf("usable vertical bounds: %+v usable %+v", p, w.Usable)
243				}
244				if !seen[p.ID] {
245					t.Fatal("missing")
246				}
247				delete(seen, p.ID)
248			}
249			if fullCount > 1 {
250				t.Fatal("multiple fullscreen")
251			}
252		}
253	})
254}
255
256// stackFocused moves the next column's window into the focused column.
257func stackFocused(w *Workspace) {
258	i := w.Focus
259	j := (i + 1) % len(w.Columns)
260	id := w.Columns[j].Windows[0]
261	w.Columns[i].Windows = append(w.Columns[i].Windows, id)
262	w.Columns[j].Windows = w.Columns[j].Windows[1:]
263	if len(w.Columns[j].Windows) == 0 {
264		w.Columns = append(w.Columns[:j], w.Columns[j+1:]...)
265		if j < i {
266			w.Focus--
267		}
268	} else if w.Columns[j].Focus >= len(w.Columns[j].Windows) {
269		w.Columns[j].Focus = len(w.Columns[j].Windows) - 1
270	}
271	w.scroll()
272}
273
274func TestLargeWidthAndBounds(t *testing.T) {
275	for _, tc := range []struct {
276		width              Width
277		usable, gaps, want int
278	}{
279		{Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
280		{Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
281		{Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
282		{Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
283		{Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
284	} {
285		if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
286			t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
287		}
288	}
289	w := workspace()
290	w.AddWindow(1)
291	w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
292	w.SetGaps(math.MaxInt)
293	for _, p := range w.Layout() {
294		if p.Rect.W != 0 || p.Rect.H < 0 {
295			t.Fatal(p)
296		}
297	}
298	w.SetOutput(-1, -1)
299	if w.Output.W != 0 || w.Output.H != 0 {
300		t.Fatal(w.Output)
301	}
302}
303func TestUnchangedUsablePreservesViewX(t *testing.T) {
304	w := workspace()
305	w.AddWindow(1)
306	w.AddWindow(2)
307	w.AddWindow(3)
308	before := w.ViewX
309	if before == 0 {
310		t.Fatal("expected a scrolled workspace")
311	}
312	w.SetUsable(w.Usable)
313	if w.ViewX != before {
314		t.Fatalf("ViewX changed from %d to %d", before, w.ViewX)
315	}
316}
317
318func TestFullscreenPersistsAndScrolls(t *testing.T) {
319	w := workspace()
320	w.AddWindow(1)
321	w.AddWindow(2)
322	w.FocusColumn(-1)
323	w.ToggleFullscreen()
324	if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X != 0 || p.Rect.W != 100 {
325		t.Fatal(p)
326	}
327	w.FocusColumn(1)
328	if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X >= 0 {
329		t.Fatal(p)
330	}
331	w.ToggleFullscreen()
332	if w.Layout()[0].Fullscreen || !w.Layout()[1].Fullscreen {
333		t.Fatal(w.Layout())
334	}
335	w.RemoveWindow(2)
336	if w.Layout()[0].Fullscreen {
337		t.Fatal(w.Layout())
338	}
339}
340
341func TestSetFullscreenStackDeactivation(t *testing.T) {
342	w := workspace()
343	w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}}
344	// A client request does not take focus; window 1 keeps it but is covered.
345	w.SetFullscreen(2, true)
346	p := w.Layout()
347	if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused {
348		t.Fatal(p)
349	}
350	w.SetFullscreen(2, false)
351	if w.Layout()[0].Hidden {
352		t.Fatal(w.Layout())
353	}
354}
355
356// Fixed overflow keeps every window on screen: past max-columns, new columns
357// split the newest one, alternating top/bottom and left/right.
358func TestFixedOverflowSpiral(t *testing.T) {
359	w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
360	w.SetOutput(100, 80)
361	for id := WindowID(1); id <= 5; id++ {
362		w.AddWindow(id)
363	}
364	got := map[WindowID]Rect{}
365	for _, p := range w.Layout() {
366		got[p.ID] = p.Rect
367	}
368	want := map[WindowID]Rect{
369		1: {X: 0, Y: 0, W: 50, H: 80},
370		2: {X: 50, Y: 0, W: 50, H: 40},  // top half of slot 2
371		3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half
372		4: {X: 75, Y: 40, W: 25, H: 20},
373		5: {X: 75, Y: 60, W: 25, H: 20},
374	}
375	if !reflect.DeepEqual(got, want) {
376		t.Fatalf("got %v\nwant %v", got, want)
377	}
378	if w.ViewX != 0 {
379		t.Fatal("fixed overflow scrolled", w.ViewX)
380	}
381	// Up to max-columns it is the plain equal split.
382	w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
383	w2.SetOutput(100, 80)
384	w2.AddWindow(1)
385	w2.AddWindow(2)
386	if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) {
387		t.Fatal(p)
388	}
389}
390
391func fixedWorkspace(maxCols, gaps, n int) *Workspace {
392	w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
393	w.SetOutput(100, 80)
394	for id := 1; id <= n; id++ {
395		w.AddWindow(WindowID(id))
396	}
397	return w
398}
399
400func placements(w *Workspace) map[WindowID]Placement {
401	got := map[WindowID]Placement{}
402	for _, p := range w.Layout() {
403		got[p.ID] = p
404	}
405	return got
406}
407
408func TestFixedOverflowEdgeCases(t *testing.T) {
409	t.Run("new windows go last whatever the focus", func(t *testing.T) {
410		w := fixedWorkspace(2, 0, 3)
411		w.FocusID(1)
412		w.AddWindow(4)
413		if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 {
414			t.Fatal(got)
415		}
416	})
417	for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot
418		t.Run("fullscreen covers the output", func(t *testing.T) {
419			w := fixedWorkspace(2, 4, 4)
420			w.FocusID(col)
421			w.ToggleFullscreen()
422			for id, p := range placements(w) {
423				if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) {
424					t.Fatal(p)
425				}
426				if id != col && !p.Hidden {
427					t.Fatalf("window %d visible over fullscreen: %+v", id, p)
428				}
429			}
430		})
431	}
432	t.Run("presets ignored", func(t *testing.T) {
433		w := fixedWorkspace(2, 0, 2)
434		w.SetPresets([]Width{{Num: 2, Den: 3}})
435		w.CycleWidth()
436		w.FocusColumn(-1)
437		w.CycleWidth()
438		for id, p := range placements(w) {
439			if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
440				t.Fatal(id, p)
441			}
442		}
443	})
444	t.Run("gaps between spiral halves", func(t *testing.T) {
445		got := placements(fixedWorkspace(1, 4, 2))
446		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}) {
447			t.Fatal(got[1].Rect, got[2].Rect)
448		}
449		if got[1].Neighbors == 0 || got[2].Neighbors == 0 {
450			t.Fatal("spiral windows must keep borders to show focus")
451		}
452	})
453}
454
455// Activating a window hidden by another's fullscreen shows it; a floating
456// target stays above the fullscreen window, which keeps it.
457func TestActivateLeavesFullscreen(t *testing.T) {
458	for _, tc := range []struct {
459		name  string
460		setup func(w *Workspace)
461		keep  bool
462	}{
463		{"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false},
464		{"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false},
465		{"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true},
466	} {
467		t.Run(tc.name, func(t *testing.T) {
468			w := &Workspace{}
469			w.SetOutput(100, 80)
470			w.AddWindow(2)
471			tc.setup(w)
472			w.FocusID(2)
473			w.SetFullscreen(2, true)
474			w.Activate(3)
475			for _, p := range w.Layout() {
476				if p.ID == 3 && (p.Hidden || !p.Focused) {
477					t.Fatalf("3 not shown and focused: %+v", p)
478				}
479				if p.ID == 2 && p.Fullscreen != tc.keep {
480					t.Fatalf("fullscreen of 2 = %v", p.Fullscreen)
481				}
482			}
483		})
484	}
485}

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