border_invariants_test.go RawHistoryFolder

1package core_test
2
3import (
4	"math/rand"
5	"slices"
6	"testing"
7	"time"
8
9	"github.com/bnema/neferwl/internal/ports"
10)
11
12// Published scenes, not just layouts: exercise the real owner loop, binds,
13// monitor transfers, unmaps, and both kinds of overflow with a fixed seed.
14func TestBorderSceneInvariants(t *testing.T) {
15	for _, overflow := range []string{"fixed", "scroll"} {
16		for _, gap := range []int{0, 6} {
17			t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
18				r := startMulti(t, func(c *ports.Config) {
19					c.Animations.On = false
20					c.Border.Width = 2
21					c.Layout.MaxColumns = 3
22					c.Layout.Overflow = overflow
23					c.Layout.Gaps = gap
24				}, left, right)
25				borderScenario(t, r, gap, nil)
26			})
27		}
28	}
29}
30
31// With animations on, the scene right after an action shows the old layout
32// moving: only the weak invariants hold there. Once every spring settled
33// (the test moves the clock and sends the page flips) the full ones do,
34// whatever the actions that ran mid-transition. Insets and lit lines come
35// from the settled topology by design: separators follow the shown layout.
36func TestBorderSceneInvariantsAnimated(t *testing.T) {
37	for _, overflow := range []string{"fixed", "scroll"} {
38		for _, gap := range []int{0, 6} {
39			t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
40				r := startSwipeOn(t, func(c *ports.Config) {
41					c.Animations.On = true
42					c.Border.Width = 2
43					c.Layout.MaxColumns = 3
44					c.Layout.Overflow = overflow
45					c.Layout.Gaps = gap
46				}, left, right)
47				moved := 0
48				borderScenario(t, r.multiRig, gap, func(set []ports.Scene) []ports.Scene {
49					settled, ok := r.settleAll(t, left.Name, right.Name)
50					if !ok {
51						return set
52					}
53					if !sameRects(set, settled) {
54						moved++
55					}
56					// Settled means no spring runs: a further flip has nothing to do.
57					if _, again := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, left.Name, right.Name); again {
58						t.Fatal("a flip after the settle still published: a spring runs")
59					}
60					return settled
61				})
62				if moved == 0 {
63					t.Fatal("no scene was ever mid-transition: the case checks nothing")
64				}
65				t.Logf("%d steps checked mid-transition and settled", moved)
66			})
67		}
68	}
69}
70
71// borderScenario drives 180 random steps and checks every scene set. With
72// settle set the steps are animated: the set a step publishes is checked
73// with the weak invariants, then the settled set (settle returns the set
74// itself when nothing animated) with the full ones. One step in four skips
75// the settling, so the next action starts mid-transition; the last step
76// always settles.
77func borderScenario(t *testing.T, r *multiRig, gap int, settle func([]ports.Scene) []ports.Scene) {
78	t.Helper()
79	rng := rand.New(rand.NewSource(20260928))
80	var ids []ports.WindowID
81	next := ports.WindowID(1)
82	// A single keypress may publish a scene even when the action is
83	// inapplicable; check every resulting scene independently.
84	keys := []struct {
85		sym  string
86		mods ports.Mods
87	}{
88		{"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},
89		{"Left", ports.ModAlt}, {"Right", ports.ModAlt},
90		{"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},
91		{"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},
92		{"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},
93		{"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},
94		{"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},
95	}
96	const steps = 180
97	for step := range steps {
98		var scenes []ports.Scene
99		switch {
100		case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:
101			scenes = r.mapWindow(t, next)
102			ids = append(ids, next)
103			next++
104		case rng.Intn(9) == 0:
105			i := rng.Intn(len(ids))
106			r.client <- ports.WindowUnmapped{ID: ids[i]}
107			scenes = receive(t, r.scenes)
108			ids = append(ids[:i], ids[i+1:]...)
109		default:
110			k := keys[rng.Intn(len(keys))]
111			scenes = r.key(t, k.sym, k.mods)
112		}
113		if settle == nil {
114			checkBorderScenes(t, step, gap, scenes)
115			continue
116		}
117		checkBorderScenesMoving(t, step, scenes)
118		if rng.Intn(4) != 0 || step == steps-1 {
119			checkBorderScenes(t, step, gap, settle(scenes))
120		}
121	}
122}
123
124// sameRects reports whether two scene sets draw every window at the same
125// rect.
126func sameRects(a, b []ports.Scene) bool {
127	if len(a) != len(b) {
128		return false
129	}
130	for i := range a {
131		if !slices.EqualFunc(a[i].Windows, b[i].Windows, func(x, y ports.SceneWindow) bool { return x.ID == y.ID && x.Rect == y.Rect }) {
132			return false
133		}
134	}
135	return true
136}
137
138// checkBorderScenesMoving is what holds for a scene published while
139// windows still move: every separator lies inside its output and belongs
140// to a window that is shown, and at most one window is focused.
141func checkBorderScenesMoving(t *testing.T, step int, scenes []ports.Scene) {
142	t.Helper()
143	focused := 0
144	for _, s := range scenes {
145		o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
146		for _, sep := range s.Separators {
147			r := sep.Rect
148			if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 || r.X+r.W > o.W || r.Y+r.H > o.H {
149				t.Fatalf("step %d %s (moving): line outside output: %+v; scene %+v", step, s.Output, sep, s)
150			}
151			if sep.Window != 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == sep.Window && !w.Hidden }) {
152				t.Fatalf("step %d %s (moving): line belongs to a hidden or absent window: %+v; scene %+v", step, s.Output, sep, s)
153			}
154		}
155		for _, w := range s.Windows {
156			if w.Focused && !w.Hidden && !w.Popup {
157				focused++
158			}
159		}
160	}
161	if focused > 1 {
162		t.Fatalf("step %d (moving): several focused windows: %+v", step, scenes)
163	}
164}
165
166func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {
167	t.Helper()
168	focused := 0
169	for _, s := range scenes {
170		checkBorderScene(t, step, gap, s)
171		for _, w := range s.Windows {
172			if w.Focused && !w.Hidden && !w.Popup && w.Rect.Overlaps(ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) {
173				focused++
174			}
175		}
176	}
177	if focused > 1 {
178		t.Fatalf("step %d: focused windows on multiple outputs: %+v", step, scenes)
179	}
180}
181
182func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
183	t.Helper()
184	o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
185	var focus *ports.SceneWindow
186	tiles := 0
187	for i := range s.Windows {
188		w := &s.Windows[i]
189		if w.Popup || w.Hidden || !w.Rect.Overlaps(o) {
190			continue
191		}
192		if w.Focused {
193			if focus != nil {
194				t.Fatalf("step %d %s: two focused windows: %+v", step, s.Output, s.Windows)
195			}
196			focus = w
197		}
198		if !w.Floating && !w.Fullscreen {
199			tiles++
200		}
201		if w.Fullscreen && w.Inset != 0 {
202			t.Fatalf("step %d %s: fullscreen inset: %+v", step, s.Output, w)
203		}
204		if !w.Floating && !w.Fullscreen {
205			if got, want := w.Inset, wantInset(s, *w, gap); got != want {
206				t.Fatalf("step %d %s: tile %d inset %v, want %v; scene %+v", step, s.Output, w.ID, got, want, s)
207			}
208		}
209	}
210	if tiles <= 1 {
211		for _, w := range s.Windows {
212			if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {
213				t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)
214			}
215		}
216	}
217	for _, sep := range s.Separators {
218		r := sep.Rect
219		if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 || r.X+r.W > o.W || r.Y+r.H > o.H {
220			t.Fatalf("step %d %s: line outside output: %+v; scene %+v", step, s.Output, sep, s)
221		}
222		if sep.Window != 0 {
223			found := false
224			for _, w := range s.Windows {
225				if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
226					found = true
227				}
228			}
229			if !found {
230				t.Fatalf("step %d %s: line belongs to invisible float: %+v; scene %+v", step, s.Output, sep, s)
231			}
232		} else if tiles < 2 {
233			t.Fatalf("step %d %s: lone tile has shared line: %+v; scene %+v", step, s.Output, sep, s)
234		}
235		if sep.Active && (focus == nil || focus.Fullscreen || sep.Window != 0 && sep.Window != focus.ID || sep.Window == 0 && focus.Floating) {
236			t.Fatalf("step %d %s: lit line without matching focused tile/float: %+v; scene %+v", step, s.Output, sep, s)
237		}
238	}
239	// A focused tile with a visible neighbor lights at least one line
240	// touching it.
241	if focus == nil || focus.Floating || focus.Fullscreen || neighborSides(s, *focus, gap) == 0 {
242		return
243	}
244	near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}
245	for _, sep := range s.Separators {
246		if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) {
247			return
248		}
249	}
250	t.Fatalf("step %d %s: focused tile %d has neighbors but no lit line; scene %+v", step, s.Output, focus.ID, s)
251}
252
253// neighborSides are the sides of tile w facing another visible tile across
254// gap, with the shared edge strictly inside the output.
255func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
256	o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
257	var sides ports.Sides
258	for _, other := range s.Windows {
259		if other.ID == w.ID || other.Popup || other.Floating || other.Fullscreen || other.Hidden || !other.Rect.Overlaps(o) {
260			continue
261		}
262		a, b := w.Rect, other.Rect
263		rows := a.Y < b.Y+b.H && b.Y < a.Y+a.H
264		cols := a.X < b.X+b.W && b.X < a.X+a.W
265		switch {
266		case rows && a.X+a.W+gap == b.X && a.X+a.W > 0 && a.X+a.W < o.W:
267			sides |= ports.SideRight
268		case rows && b.X+b.W+gap == a.X && a.X > 0 && a.X < o.W:
269			sides |= ports.SideLeft
270		case cols && a.Y+a.H+gap == b.Y && a.Y+a.H > 0 && a.Y+a.H < o.H:
271			sides |= ports.SideBottom
272		case cols && b.Y+b.H+gap == a.Y && a.Y > 0 && a.Y < o.H:
273			sides |= ports.SideTop
274		}
275	}
276	return sides
277}
278
279// wantInset is the exact inset of a visible tile: every neighbor side with
280// gaps, only the right and bottom (shared-line owner) sides without.
281func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
282	sides := neighborSides(s, w, gap)
283	if gap == 0 {
284		sides &= ports.SideRight | ports.SideBottom
285	}
286	return sides
287}
288
289// Regression for the original two-monitor transfer: when the last tile
290// comes back, neither the old output nor the new one has a shared line.
291func TestBorderMoveBackAlone(t *testing.T) {
292	r := startMulti(t, func(c *ports.Config) {
293		c.Border.Width = 2
294		c.Layout.MaxColumns = 3
295		c.Layout.Overflow = "fixed"
296	}, left, right)
297	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
298	r.mapWindow(t, 1)
299	for _, sym := range []string{"Left", "Right"} {
300		scenes := r.key(t, sym, ports.ModAlt|ports.ModShift)
301		checkBorderScenes(t, 0, 0, scenes)
302		if got := shown(scenes); len(got["DP-1"]) != map[string]int{"Left": 1, "Right": 0}[sym] || len(got["DP-2"]) != map[string]int{"Left": 0, "Right": 1}[sym] {
303			t.Fatalf("%s: window did not move: %v", sym, got)
304		}
305	}
306}

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