feat/data-dnd e05cb15

stash_swipe_internal_test.go RawHistoryFolder

1package core
2
3import (
4	"math"
5	"testing"
6	"time"
7
8	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9	"github.com/bnema/neferwl/internal/ports"
10)
11
12// stashSwipe drives a three-finger swipe on the stash of stashRig (windows
13// 4, 3, 2; 2 selected at the right end).
14type stashSwipe struct {
15	t  *testing.T
16	c  *Core
17	ic *indicatorClock
18	at time.Duration
19}
20
21func newStashSwipe(t *testing.T, edit func(*Core)) (*stashSwipe, *Workspace) {
22	t.Helper()
23	c, ic, sc, _ := stashRig(t, true)
24	if edit != nil {
25		edit(c)
26	}
27	return &stashSwipe{t: t, c: c, ic: ic}, sc.mon.Current()
28}
29
30func (s *stashSwipe) begin() {
31	s.at += time.Second
32	s.c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: s.at})
33}
34
35func (s *stashSwipe) move(dx float64) {
36	s.at += 8 * time.Millisecond
37	s.c.swipeUpdate(ports.SwipeUpdate{DX: dx, Time: s.at})
38}
39
40func (s *stashSwipe) end(cancelled bool) {
41	s.c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: s.at})
42}
43
44// flick is a quick swipe of n updates of dx, lifted at once.
45func (s *stashSwipe) flick(n int, dx float64) {
46	s.begin()
47	for range n {
48		s.move(dx)
49	}
50	s.end(false)
51}
52
53// The selection is the right end of 4, 3, 2. Fingers moving right bring the
54// left neighbor in (natural scroll off: the content follows the fingers
55// opposite, so sideways left reaches the left neighbor like focus left).
56
57// The view follows the fingers: halfway to the neighbor it has moved half a
58// window, its veil half, and nothing is selected yet.
59func TestStashSwipeFollowsFingers(t *testing.T) {
60	s, w := newStashSwipe(t, nil)
61	s.begin()
62	s.move(-10) // under the decision threshold
63	if w.stashView.off != 0 {
64		t.Fatalf("moved %v before picking an axis", w.stashView.off)
65	}
66	s.move(-20)
67	if g := s.c.swipe; g == nil || g.mode != swipeStash {
68		t.Fatalf("swipe %+v, want a stash swipe", g)
69	}
70	before := w.stashView.off
71	s.move(-40)
72	if !(w.stashView.off < before) || w.stashAt != 2 {
73		t.Fatalf("offset %v then %v, stashAt %d: the view must follow and the selection stay", before, w.stashView.off, w.stashAt)
74	}
75	sc := sceneWindow(t, indicatorScene(t, s.c), 2)
76	if sc.Dim <= 0 || sc.Dim >= 0.5 {
77		t.Fatalf("selected window veil %v mid-swipe, want strictly between 0 and the stash dim", sc.Dim)
78	}
79}
80
81// A flick of any length lands on the neighbor at most: no window is skipped.
82func TestStashSwipeNeverSkipsAWindow(t *testing.T) {
83	for _, n := range []int{3, 10, 40, 200} {
84		s, w := newStashSwipe(t, nil)
85		s.flick(n, -80)
86		if w.stashAt != 1 {
87			t.Fatalf("%d updates: stashAt %d, want 1 (one window at most)", n, w.stashAt)
88		}
89		if !w.stashFocus {
90			t.Fatalf("%d updates: the stash lost the focus", n)
91		}
92		// The view itself never went past the neighbor either.
93		if view := float64(w.stashAt) + w.stashView.off; view < 1-0.1 {
94			t.Fatalf("%d updates: the view is at %v, past the neighbor at 1", n, view)
95		}
96	}
97}
98
99// A slow short swipe settles back where it began.
100func TestStashSwipeShortSwipeReturns(t *testing.T) {
101	s, w := newStashSwipe(t, nil)
102	s.begin()
103	s.move(-20)
104	s.at += 2 * time.Second
105	s.move(-30)
106	s.at += 2 * time.Second
107	s.end(false)
108	if w.stashAt != 2 {
109		t.Fatalf("stashAt %d after a slow short swipe, want 2", w.stashAt)
110	}
111	s.ic.now = s.ic.now.Add(5 * time.Second)
112	s.c.animate(s.ic.now, nil)
113	if w.stashView.off != 0 || w.stashView.motion.on {
114		t.Fatalf("offset %v, spring %v: want settled", w.stashView.off, w.stashView.motion.on)
115	}
116}
117
118// A cancelled swipe returns to the selection.
119func TestStashSwipeCancelReturns(t *testing.T) {
120	s, w := newStashSwipe(t, nil)
121	s.begin()
122	for range 20 {
123		s.move(-40)
124	}
125	s.end(true)
126	if w.stashAt != 2 {
127		t.Fatalf("stashAt %d after a cancel", w.stashAt)
128	}
129}
130
131// Past the first or last window the view only stretches: the end of the
132// stash holds the selection.
133func TestStashSwipeEdgeHolds(t *testing.T) {
134	s, w := newStashSwipe(t, nil)
135	s.flick(20, 80) // right: past the last window
136	if w.stashAt != 2 {
137		t.Fatalf("stashAt %d past the end, want 2", w.stashAt)
138	}
139	s.flick(20, -80)
140	s.flick(20, -80)
141	s.ic.now = s.ic.now.Add(5 * time.Second)
142	s.c.animate(s.ic.now, nil)
143	s.flick(20, -80) // 4 is first now: past the start
144	if w.stashAt != 0 {
145		t.Fatalf("stashAt %d, want 0 at the start", w.stashAt)
146	}
147}
148
149// Natural scroll moves the view the other way, as for columns.
150func TestStashSwipeNaturalScrollInverts(t *testing.T) {
151	s, w := newStashSwipe(t, func(c *Core) { c.cfg.Touchpad.NaturalScroll = true })
152	s.flick(10, 80)
153	if w.stashAt != 1 {
154		t.Fatalf("stashAt %d with natural scroll, want 1", w.stashAt)
155	}
156}
157
158// With animations off a swipe takes the discrete path (nothing slides) and
159// still steps one window.
160func TestStashSwipeAnimationsOffStepsOnce(t *testing.T) {
161	s, w := newStashSwipe(t, func(c *Core) { c.cfg.Animations.On = false })
162	s.flick(40, -80)
163	if w.stashAt != 1 || w.stashView.off != 0 {
164		t.Fatalf("stashAt %d, offset %v with animations off", w.stashAt, w.stashView.off)
165	}
166}
167
168// A bind during the swipe lets go of it: the rest does nothing.
169func TestStashSwipeDropsWhenSelectionChanges(t *testing.T) {
170	s, w := newStashSwipe(t, nil)
171	s.begin()
172	s.move(-40)
173	s.move(-40)
174	s.c.applyAction(ActionFocusColumnLeft)
175	s.move(-40)
176	if g := s.c.swipe; g.mode != swipeDropped {
177		t.Fatalf("mode %v, want dropped", g.mode)
178	}
179	at := w.stashAt
180	s.end(false)
181	if w.stashAt != at || w.stashView.off != 0 {
182		t.Fatalf("stashAt %d then %d, offset %v: the dropped swipe moved the stash", at, w.stashAt, w.stashView.off)
183	}
184}
185
186// Fingers on the stash take a running landing slide where it is.
187func TestStashSwipeCatchesLandingSlide(t *testing.T) {
188	s, w := newStashSwipe(t, nil)
189	s.flick(10, -80)
190	s.ic.now = s.ic.now.Add(30 * time.Millisecond)
191	s.c.animate(s.ic.now, nil)
192	mid := w.stashView.off
193	if mid == 0 || !w.stashView.motion.on {
194		t.Fatalf("setup: offset %v, spring %v", mid, w.stashView.motion.on)
195	}
196	s.begin()
197	s.move(-20)
198	s.move(-20)
199	if w.stashView.motion.on {
200		t.Fatal("the landing spring still runs under the fingers")
201	}
202}
203
204// The stash never brings another workspace on screen: its landing must not
205// report a shown workspace, which would respawn the empty slots of a named
206// one.
207func TestStashSwipeLandingShowsNoWorkspace(t *testing.T) {
208	s, w := newStashSwipe(t, nil)
209	s.begin()
210	for range 10 {
211		s.move(-80)
212	}
213	if shown, _ := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {
214		t.Fatal("a stash landing reported a workspace shown")
215	}
216	if w.stashAt != 1 {
217		t.Fatalf("stashAt %d, want the landing on 1", w.stashAt)
218	}
219}
220
221// A native float with the focus over the stash keeps the swipe discrete: the
222// stash must not slide behind it and snap back. One that takes the focus
223// mid-swipe lets go of the stash.
224func TestStashSwipeUnderFocusedFloatIsDiscrete(t *testing.T) {
225	s, w := newStashSwipe(t, nil)
226	w.AddFloating(9, 100, 80)
227	if !w.floatFocus {
228		t.Fatal("setup: the float has no focus")
229	}
230	s.begin()
231	s.move(-40)
232	if g := s.c.swipe; g.mode != swipeDiscrete {
233		t.Fatalf("mode %v, want discrete", g.mode)
234	}
235	if w.stashView.off != 0 {
236		t.Fatalf("stash slid by %v under a focused float", w.stashView.off)
237	}
238}
239
240func TestStashSwipeDropsWhenAFloatTakesTheFocus(t *testing.T) {
241	s, w := newStashSwipe(t, nil)
242	s.begin()
243	s.move(-40)
244	s.move(-40)
245	w.AddFloating(9, 100, 80)
246	s.move(-40)
247	if g := s.c.swipe; g.mode != swipeDropped || w.stashView.off != 0 {
248		t.Fatalf("mode %v, offset %v: want dropped and settled", g.mode, w.stashView.off)
249	}
250}
251
252// A session lock lets go of the swipe and settles the stash it moved.
253func TestStashSwipeSecurityLockSettlesTheStash(t *testing.T) {
254	s, w := newStashSwipe(t, nil)
255	s.begin()
256	s.move(-40)
257	s.move(-40)
258	if w.stashView.off == 0 {
259		t.Fatal("setup: the stash did not follow")
260	}
261	gate := portsmocks.NewMockSessionSecurity(t)
262	gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
263	s.c.opts.Security = gate
264	if !s.c.syncSecurity() {
265		t.Fatal("the lock was not taken")
266	}
267	if s.c.swipe != nil || w.stashView.off != 0 {
268		t.Fatalf("swipe %v, offset %v after the lock", s.c.swipe, w.stashView.off)
269	}
270}
271
272// With the focus on another output when the fingers lift, the selection
273// stays and the view springs back.
274func TestStashSwipeLiftOnAnotherOutputSpringsBack(t *testing.T) {
275	s, w := newStashSwipe(t, nil)
276	s.c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
277	s.begin()
278	s.move(-40)
279	s.move(-80)
280	s.move(-80)
281	s.c.focusScreen = 1
282	s.end(false)
283	if w.stashAt != 2 {
284		t.Fatalf("stashAt %d, want the selection kept at 2", w.stashAt)
285	}
286	if !w.stashView.motion.on {
287		t.Fatal("the view does not spring back")
288	}
289	s.ic.now = s.ic.now.Add(5 * time.Second)
290	s.c.animate(s.ic.now, nil)
291	if w.stashView.off != 0 {
292		t.Fatalf("offset %v after the spring", w.stashView.off)
293	}
294}
295
296// A swipe whose screen is gone lets go of the stash it shifted.
297func TestStashSwipeLosingItsScreenSettlesTheStash(t *testing.T) {
298	for _, onEnd := range []bool{false, true} {
299		s, w := newStashSwipe(t, nil)
300		s.begin()
301		s.move(-40)
302		s.move(-40)
303		if w.stashView.off == 0 {
304			t.Fatal("setup: the stash did not follow")
305		}
306		s.c.swipe.screen = &screen{} // its output unplugged
307		if onEnd {
308			s.end(false)
309		} else {
310			s.move(-40)
311		}
312		if w.stashView.off != 0 || s.c.swipe != nil {
313			t.Fatalf("end=%v: offset %v, swipe %v: want settled and gone", onEnd, w.stashView.off, s.c.swipe)
314		}
315	}
316}
317
318// The overview shows the settled state: a stash landing spring stops.
319func TestOverviewStopsStashSlide(t *testing.T) {
320	s, w := newStashSwipe(t, nil)
321	s.flick(10, -80)
322	if !w.stashView.motion.on {
323		t.Fatal("setup: no landing spring")
324	}
325	s.c.cur().mon.ToggleOverview()
326	if w.stashView.motion.on || w.stashView.off != 0 {
327		t.Fatalf("spring %v, offset %v under the overview", w.stashView.motion.on, w.stashView.off)
328	}
329}
330
331// Quick chained navigations keep the view where it is drawn, however far it
332// is from the selection: nothing jumps, and the slide settles on the last.
333func TestStashNavigationChainsManyWithoutJump(t *testing.T) {
334	c, ic, sc, _ := stashRig(t, true)
335	w := sc.mon.Current()
336	for id := WindowID(5); id <= 8; id++ {
337		sc.mon.AddWindow(id)
338		w.FocusID(id)
339		c.applyAction(ActionToggleWindowStash)
340	}
341	settleShown(t, c)
342	if len(w.Stash) != 7 {
343		t.Fatalf("setup: %d stashed", len(w.Stash))
344	}
345	prev := float64(w.stashAt) + w.stashView.off
346	for i := range 5 {
347		act(c, ic, ActionFocusColumnLeft)
348		pos := float64(w.stashAt) + w.stashView.off
349		if pos < prev-1e-6 || pos > prev+1e-6 {
350			t.Fatalf("navigation %d: the drawn view jumped from %v to %v", i, prev, pos)
351		}
352		ic.now = ic.now.Add(10 * time.Millisecond)
353		c.animate(ic.now, nil)
354		prev = float64(w.stashAt) + w.stashView.off
355	}
356	// The view is still several windows from the selection: the window it
357	// is on is drawn there, however far it is from stashAt.
358	at := int(math.Round(float64(w.stashAt) + w.stashView.off))
359	if at-w.stashAt < 3 {
360		t.Fatalf("setup: the view is %d windows from the selection", at-w.stashAt)
361	}
362	near := w.Stash[at].ID
363	if got := sceneWindow(t, indicatorScene(t, c), near); got.Hidden || got.Rect.W == 0 {
364		t.Fatalf("window %d, %d away from the selection, is not drawn under the view: %+v", near, at-w.stashAt, got)
365	}
366	ic.now = ic.now.Add(5 * time.Second)
367	c.animate(ic.now, nil)
368	if w.stashView.off != 0 || w.stashView.motion.on {
369		t.Fatalf("offset %v, spring %v: want settled", w.stashView.off, w.stashView.motion.on)
370	}
371	if got := sceneWindow(t, indicatorScene(t, c), near); !got.Hidden {
372		t.Fatalf("window %d still drawn after the settle: %+v", near, got)
373	}
374}
375
376// A captured workspace caught mid-slide draws the veil the screen does:
377// a share of Stash.Dim, not the full one and not none.
378func TestCaptureSceneDrawsTheSlidingVeil(t *testing.T) {
379	s, w := newStashSwipe(t, nil)
380	s.c.cfg.Stash.Dim = 0.5
381	s.begin()
382	s.move(-40)
383	s.move(-80)
384	sc := s.c.screens[0]
385	s.c.configures.cw.load(sc, w)
386	scene := s.c.captureScene(1)
387	if scene == nil {
388		t.Fatal("no capture scene")
389	}
390	var partial bool
391	for _, p := range s.c.configures.cw.placements {
392		if p.Veil <= 0 || p.Veil >= 1 {
393			continue
394		}
395		partial = true
396		got := sceneWindow(t, *scene, p.ID).Dim
397		if want := s.c.cfg.Stash.Dim * p.Veil; math.Abs(got-want) > 1e-9 {
398			t.Fatalf("window %d captured with dim %v, want %v", p.ID, got, want)
399		}
400	}
401	if !partial {
402		t.Fatal("setup: no window has a partial veil")
403	}
404}
405
406// An output unplugged under the swipe: the host adopts its workspaces, and
407// the stash they carry must not stay shifted.
408func TestStashSwipeUnpluggedOutputSettlesTheAdoptedStash(t *testing.T) {
409	s, w := newStashSwipe(t, nil)
410	s.c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
411	s.begin()
412	s.move(-40)
413	s.move(-80)
414	if w.stashView.off == 0 {
415		t.Fatal("setup: the stash did not follow")
416	}
417	s.c.removeScreen("A")
418	if s.c.swipe != nil || w.stashView.off != 0 {
419		t.Fatalf("swipe %v, offset %v after the unplug", s.c.swipe, w.stashView.off)
420	}
421}

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