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}