main f2d166b

pointergrab_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"slices"
6	"testing"
7	"time"
8
9	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10	"github.com/bnema/neferwl/internal/ports"
11)
12
13type pointerRig struct {
14	*Core
15	commands chan ports.ClientCommand
16}
17
18func borderCore(t *testing.T, row bool) (*pointerRig, *Workspace) {
19	t.Helper()
20	commands := make(chan ports.ClientCommand, 1024)
21	c := &pointerRig{Core: dragCore(t), commands: commands}
22	c.ch.Commands = commands
23	w := c.cur().mon.Current()
24	w.Overflow = OverflowFixed
25	w.SetMaxColumns(2)
26	w.SetGaps(10)
27	w.AddWindow(1)
28	w.AddWindow(2)
29	if row {
30		w.Columns = []Column{{Windows: []WindowID{1, 2}}}
31		w.Focus = 0
32	}
33	rs := w.columnRects()
34	c.cursorX, c.cursorY = float64(rs[0].X+rs[0].W+w.gap()/2), 100
35	if row {
36		rows := rowRects(rs[0], w.Columns[0], w.gap())
37		c.cursorX, c.cursorY = 100, float64(rows[0].Y+rows[0].H+w.gap()/2)
38	}
39	c.mods = ports.ModSuper
40	return c, w
41}
42
43func grabButton(t *testing.T, c *pointerRig, b uint32, down bool, at time.Duration) {
44	t.Helper()
45	if err := c.pointerButton(context.Background(), ports.PointerButton{Button: b, Pressed: down, Time: at}); err != nil {
46		t.Fatal(err)
47	}
48}
49
50func grabMove(t *testing.T, c *pointerRig, x, y float64) {
51	t.Helper()
52	if err := c.pointerMotion(context.Background(), ports.PointerMotion{X: x, Y: y, Time: time.Second}); err != nil {
53		t.Fatal(err)
54	}
55}
56
57func grabCommands(c *pointerRig) []ports.ClientCommand {
58	var out []ports.ClientCommand
59	for len(c.commands) > 0 {
60		out = append(out, <-c.commands)
61	}
62	return out
63}
64
65func requireOverride(t *testing.T, cmds []ports.ClientCommand, shape string) {
66	t.Helper()
67	if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool { v, ok := cmd.(ports.CursorOverride); return ok && v.Shape == shape }) {
68		t.Fatalf("missing cursor %q in %+v", shape, cmds)
69	}
70}
71
72func TestBorderPointerGrab(t *testing.T) {
73	for _, row := range []bool{false, true} {
74		t.Run(map[bool]string{false: "column", true: "row"}[row], func(t *testing.T) {
75			c, w := borderCore(t, row)
76			before := w.Layout()
77			grabButton(t, c, btnRight, true, time.Second)
78			if c.pgrab == nil || c.pointer != 0 || c.drag != nil {
79				t.Fatal("no exclusive border grab")
80			}
81			shape := "ew-resize"
82			if row {
83				shape = "ns-resize"
84			}
85			requireOverride(t, grabCommands(c), shape)
86			x, y := c.cursorX+150, c.cursorY
87			if row {
88				x, y = c.cursorX, c.cursorY+100
89			}
90			grabMove(t, c, x, y)
91			after := w.Layout()
92			if before[0].Rect == after[0].Rect || before[1].Rect == after[1].Rect {
93				t.Fatal("tiles did not resize", before, after)
94			}
95			cmds := grabCommands(c)
96			for _, id := range []WindowID{1, 2} {
97				if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool {
98					v, ok := cmd.(ports.ConfigureWindow)
99					r := after[int(id)-1].Rect
100					return ok && v.ID == id && v.Width == r.W-2*w.border && v.Height == r.H-2*w.border
101				}) {
102					t.Fatalf("no live configure for %d: %+v", id, cmds)
103				}
104			}
105			grabButton(t, c, btnRight, false, 2*time.Second)
106			if c.pgrab != nil || !slices.Equal(w.Layout(), after) {
107				t.Fatal("release changed sizes")
108			}
109			requireOverride(t, grabCommands(c), "")
110		})
111	}
112}
113
114func TestBorderGrabEscape(t *testing.T) {
115	for _, row := range []bool{false, true} {
116		c, w := borderCore(t, row)
117		before := w.Layout()
118		grabButton(t, c, btnRight, true, time.Second)
119		grabMove(t, c, c.cursorX+100, c.cursorY+100)
120		grabCommands(c)
121		if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Escape", Pressed: true}); err != nil {
122			t.Fatal(err)
123		}
124		if c.pgrab != nil || !slices.Equal(w.Layout(), before) {
125			t.Fatal("cancel did not restore", w.Layout(), before)
126		}
127		requireOverride(t, grabCommands(c), "")
128		if !c.swallow[btnRight] {
129			t.Fatal("release not swallowed")
130		}
131		grabButton(t, c, btnRight, false, 2*time.Second)
132		if c.buttons[btnRight] || c.swallow[btnRight] {
133			t.Fatal("release still held")
134		}
135	}
136}
137
138func TestBorderGrabOtherButtons(t *testing.T) {
139	c, _ := borderCore(t, false)
140	grabButton(t, c, btnRight, true, time.Second)
141	grabCommands(c)
142	grabButton(t, c, btnLeft, true, time.Second)
143	grabButton(t, c, btnLeft, false, time.Second)
144	if c.pgrab == nil || len(grabCommands(c)) != 0 || c.buttons[btnLeft] {
145		t.Fatal("other button escaped grab")
146	}
147}
148
149func TestBorderGrabPreconditions(t *testing.T) {
150	for _, kind := range []string{"no-cmd", "interior", "overview", "preview", "synthetic", "held", "inhibited"} {
151		t.Run(kind, func(t *testing.T) {
152			c, _ := borderCore(t, false)
153			switch kind {
154			case "no-cmd":
155				c.mods = 0
156			case "interior":
157				c.cursorX = 100
158			case "overview":
159				c.cur().mon.ov.open = true
160			case "preview":
161				c.cur().mon.sw = switcherState{ws: c.cur().mon.Current(), shown: true}
162			case "synthetic":
163				c.synthetic = true
164			case "held":
165				c.buttons[btnLeft] = true
166			case "inhibited":
167				c.keyboard.inhibiting = 1
168				c.cur().mon.Current().FocusID(1)
169			}
170			started, err := c.startPointerGrab(context.Background(), ports.PointerButton{Button: btnRight, Pressed: true})
171			if err != nil || started || c.pgrab != nil {
172				t.Fatal(started, err)
173			}
174		})
175	}
176}
177
178func TestBorderGrabFloatFallback(t *testing.T) {
179	c, w := borderCore(t, false)
180	w.AddFloating(3, 200, 200)
181	r := w.floatRect(w.Floats[0])
182	c.pointer = 3
183	c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+r.H/2)
184	grabButton(t, c, btnRight, true, time.Second)
185	if c.pgrab != nil || c.drag == nil || !c.drag.resize || !c.drag.float {
186		t.Fatal("float resize lost")
187	}
188}
189
190func TestBorderDoubleClick(t *testing.T) {
191	for _, row := range []bool{false, true} {
192		c, w := borderCore(t, row)
193		grabButton(t, c, btnRight, true, time.Second)
194		grabMove(t, c, c.cursorX+100, c.cursorY+100)
195		grabButton(t, c, btnRight, false, 1100*time.Millisecond)
196		grabButton(t, c, btnRight, true, 1200*time.Millisecond)
197		if c.pgrab != nil || !c.swallow[btnRight] || c.lastBorderClick.ws != nil {
198			t.Fatal("not reset")
199		}
200		for _, col := range w.Columns {
201			if col.Span != 0 || col.Shares != nil {
202				t.Fatal("manual size remains", col)
203			}
204		}
205		grabButton(t, c, btnRight, false, 1300*time.Millisecond)
206	}
207}
208
209func TestBorderGrabInvalidation(t *testing.T) {
210	for _, change := range []string{"window", "workspace", "output"} {
211		t.Run(change, func(t *testing.T) {
212			c, w := borderCore(t, false)
213			grabButton(t, c, btnRight, true, time.Second)
214			grabCommands(c)
215			switch change {
216			case "window":
217				if err := c.unmapClient(context.Background(), ports.WindowUnmapped{ID: 2}); err != nil {
218					t.Fatal(err)
219				}
220			case "workspace":
221				c.cur().mon.Workspaces = append(c.cur().mon.Workspaces, workspace())
222				c.cur().mon.Active = len(c.cur().mon.Workspaces) - 1
223			case "output":
224				c.screens = []*screen{dragCore(t).cur()}
225			}
226			if c.pgrab != nil {
227				if err := c.pgrab.motion(context.Background(), time.Second); err != nil {
228					t.Fatal(err)
229				}
230			}
231			if c.pgrab != nil || !c.swallow[btnRight] {
232				t.Fatal("stale grab survives", w.Columns)
233			}
234			requireOverride(t, grabCommands(c), "")
235		})
236	}
237}
238
239func TestBorderGrabSecurityReset(t *testing.T) {
240	c, _ := borderCore(t, false)
241	grabButton(t, c, btnRight, true, time.Second)
242	grabCommands(c)
243	gate := portsmocks.NewMockSessionSecurity(t)
244	gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
245	c.opts.Security = gate
246	if !c.syncSecurity() || c.pgrab != nil {
247		t.Fatal("grab survived protection")
248	}
249	if len(grabCommands(c)) != 0 {
250		t.Fatal("security reset sent commands")
251	}
252}
253
254func TestPointerGrabValidityContract(t *testing.T) {
255	c, _ := borderCore(t, false)
256	grabButton(t, c, btnRight, true, time.Second)
257	if !c.pgrab.valid() {
258		t.Fatal("new grab is invalid")
259	}
260	c.screens = nil
261	if c.pgrab.valid() {
262		t.Fatal("grab survived output removal")
263	}
264}
265
266func TestBorderGrabFullscreenPublish(t *testing.T) {
267	c, w := borderCore(t, false)
268	w.Overflow = OverflowScroll
269	grabButton(t, c, btnRight, true, time.Second)
270	if c.pgrab == nil {
271		t.Fatal("grab not started")
272	}
273	grabCommands(c)
274	w.fullscreen = 1
275	if err := c.publish(context.Background()); err != nil {
276		t.Fatal(err)
277	}
278	if c.pgrab != nil || !c.swallow[btnRight] {
279		t.Fatal("fullscreen did not abort grab and swallow release")
280	}
281	requireOverride(t, grabCommands(c), "")
282	grabButton(t, c, btnRight, false, 2*time.Second)
283	for _, cmd := range grabCommands(c) {
284		if _, ok := cmd.(ports.PointerButtonTo); ok {
285			t.Fatal("release forwarded")
286		}
287	}
288	if c.buttons[btnRight] || c.swallow[btnRight] {
289		t.Fatal("release still held")
290	}
291}

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