1package core
2
3import (
4 "math"
5 "slices"
6 "time"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11// A three-finger swipe follows the fingers: sideways it scrolls the columns,
12// up or down it slides between workspaces. Each swipe moves one step at
13// most (snapswipe.go): the next column edge or the next workspace. When
14// the fingers lift, a quick swipe goes on to that step, a slow one settles
15// on the closest, and a spring takes the view there (motion.go). Sideways
16// over the shown stash the swipe slides the stash the same way, one window
17// at most. Where the view cannot slide (fixed overflow, a float, a
18// fullscreen window), the swipe runs one focus action instead.
19// In the overview, three fingers use distance-based selection during movement.
20
21const (
22 // swipeDecide is the touchpad distance before the swipe picks its axis
23 // (GNOME Shell's threshold).
24 swipeDecide = 16.0
25 // viewSwipeMovement is the touchpad distance that scrolls the columns by
26 // one usable width.
27 viewSwipeMovement = 1200.0
28 // workspaceSwipeMovement is the touchpad distance that slides one
29 // workspace.
30 workspaceSwipeMovement = 300.0
31 // stashSwipeMovement is the touchpad distance that slides the stash by
32 // one window.
33 stashSwipeMovement = 300.0
34)
35
36type swipeMode uint8
37
38const (
39 swipeUndecided swipeMode = iota
40 swipeColumns
41 swipeWorkspaces
42 swipeDiscrete
43 // swipeNavigation navigates overview cards during a three-finger gesture.
44 swipeNavigation
45 // swipeOverview is a vertical four-finger swipe: up opens the
46 // overview, down closes it on the selection (niri's gesture). With
47 // the overview closed, down shows or hides the stash, and up hides
48 // it when it is shown, else opens the overview.
49 swipeOverview
50 // swipeStash is a sideways swipe over the shown stash: its view
51 // follows the fingers, with a stop on each window, and lands on a
52 // neighbor of the selection at most.
53 swipeStash
54 // swipeDropped ignores the rest of a swipe whose workspace changed.
55 swipeDropped
56)
57
58// swipeGesture is the swipe in progress, on the screen focused when it began.
59type swipeGesture struct {
60 screen *screen
61 fingers int
62 mode swipeMode
63 horizontal bool
64 cx, cy float64
65 // snap follows the movement along the chosen axis, natural scroll
66 // applied: positive scrolls right or down. Its view is absolute: the
67 // columns' x in pixels or the monitor's fractional workspace index,
68 // so a focus change during the swipe does not move the fingers' view.
69 // A discrete or overview swipe counts steps of -1, 0 or 1.
70 snap snapSwipe
71 // ws is the workspace whose columns scroll, or the active one when a
72 // workspace slide began.
73 ws *Workspace
74 // points are ws's column snap points when a column swipe began: the
75 // swipe ends if the layout no longer has them.
76 points []float64
77 // list is the numbered workspaces when a workspace slide began: the
78 // slide indexes it, so it ends if the list changes.
79 list []*Workspace
80 // ids and home are the stash windows and the selection when a stash
81 // swipe began: it ends if either changes.
82 ids []WindowID
83 home int
84 // opens is the monitor's overviewOpens when the swipe picked its
85 // mode: a slide the overview opened over is dropped, even once the
86 // overview closed again.
87 opens int
88}
89
90// listChanged reports whether the numbered workspaces or the active one
91// changed since a workspace slide began, or the overview opened over it.
92func (g *swipeGesture) listChanged(m *Monitor) bool {
93 return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || g.horizontal != (g.ws.policy().workspace == horizontalAxis) || !slices.Equal(m.Workspaces, g.list)
94}
95
96// columnsChanged reports whether a column swipe lost its workspace: it is
97// no longer shown or slidable, its snap points moved (an output resize, a
98// panel, a window mapped or resized, other gaps), or the overview opened
99// over it.
100func (g *swipeGesture) columnsChanged(m *Monitor) bool {
101 if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || g.horizontal != (g.ws.policy().content == horizontalAxis) {
102 return true
103 }
104 if g.ws.policy().wraps {
105 n, spacing := g.ws.cascadeBands()
106 if len(g.points) != n {
107 return true
108 }
109 for i, p := range g.points {
110 if p != float64(i*spacing) {
111 return true
112 }
113 }
114 return false
115 }
116 return !slices.Equal(g.ws.snapPoints(), g.points)
117}
118
119// stashChanged reports whether a stash swipe lost its stash: it is no
120// longer shown or focused, its windows or selection changed, or the
121// overview opened over it.
122func (g *swipeGesture) stashChanged(m *Monitor) bool {
123 w := g.ws
124 return m.overviewOpens != g.opens || m.Current() != w || !w.stashFocused() || w.floatFocus || !w.stashShown() || w.stashAt != g.home ||
125 !slices.EqualFunc(w.Stash, g.ids, func(f Float, id WindowID) bool { return f.ID == id })
126}
127
128// swipeSign turns finger movement into view movement: natural scroll moves
129// the content with the fingers.
130func (c *Core) swipeSign() float64 {
131 if c.cfg.Touchpad.NaturalScroll {
132 return -1
133 }
134 return 1
135}
136
137// swipeBegin starts a swipe. Input streams one swipe at a time; one still
138// running (its end was lost) ends cancelled first: its slide settles back.
139func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
140 changed := false
141 if c.switching() {
142 c.cancelSwitcher()
143 changed = true
144 }
145 if c.swipe != nil {
146 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
147 changed = true
148 }
149 c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
150 if b.Fingers == 3 && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
151 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
152 c.swipe.mode = swipeNavigation
153 }
154 return changed
155}
156
157// swipeUpdate moves the swipe; it reports whether the scene changed.
158func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
159 g := c.swipe
160 if g == nil {
161 return false
162 }
163 if !c.hasScreen(g.screen) {
164 // Its screen went: let go of what it moved, or that stays shifted.
165 c.dropSwipe()
166 c.swipe = nil
167 return false
168 }
169 if g.mode == swipeNavigation {
170 m := g.screen.mon
171 if !m.ov.open || c.cur() != g.screen || c.overviewKeyboardTaken() {
172 g.mode = swipeDropped
173 return false
174 }
175 now := c.now()
176 shots := c.snapshot(now)
177 changed := m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger,
178 Horizontal: ports.ScrollAxis{Set: true, Value: u.DX * c.swipeSign()},
179 Vertical: ports.ScrollAxis{Set: true, Value: u.DY * c.swipeSign()}})
180 if changed {
181 c.keyboard.takeBack()
182 c.transition(shots, now)
183 }
184 return changed
185 }
186 if g.mode == swipeUndecided {
187 g.cx += u.DX
188 g.cy += u.DY
189 if g.cx*g.cx+g.cy*g.cy < swipeDecide*swipeDecide {
190 return false
191 }
192 c.decide(g)
193 }
194 d := u.DY
195 if g.horizontal {
196 d = u.DX
197 }
198 if g.mode == swipeOverview {
199 // Like niri, the overview gesture ignores natural scroll: the
200 // fingers going up open it.
201 g.snap.push(d, u.Time)
202 return false
203 }
204 g.snap.push(d*c.swipeSign(), u.Time)
205 m := g.screen.mon
206 switch g.mode {
207 case swipeColumns:
208 if g.columnsChanged(m) {
209 g.ws.view.stop()
210 g.mode = swipeDropped
211 return true
212 }
213 g.ws.view.off = g.snap.pos() - float64(g.ws.View)
214 return true
215 case swipeWorkspaces:
216 if g.listChanged(m) {
217 m.stopSwitch()
218 g.mode = swipeDropped
219 return true
220 }
221 m.switchView.off = g.snap.pos() - float64(m.Active)
222 return true
223 case swipeStash:
224 if g.stashChanged(m) {
225 g.ws.stashView.stop()
226 g.mode = swipeDropped
227 return true
228 }
229 g.ws.stashView.off = g.snap.pos() - float64(g.ws.stashAt)
230 return true
231 }
232 return false
233}
234
235// decide picks the swipe's axis and what it moves. A running slide stops
236// where it is: the fingers take it from there.
237func (c *Core) decide(g *swipeGesture) {
238 g.horizontal = math.Abs(g.cx) > math.Abs(g.cy)
239 m := g.screen.mon
240 g.opens = m.overviewOpens
241 w := m.Current()
242 switch {
243 case g.fingers == 4 && !g.horizontal:
244 g.mode, g.snap = swipeOverview, newStepSwipe()
245 case g.fingers == 4:
246 // Four fingers sideways do nothing.
247 g.mode = swipeDropped
248 case m.ov.open:
249 // Overview navigation is chosen at begin, never on release.
250 g.mode = swipeDropped
251 case !c.animOn():
252 // With animations off nothing slides: the swipe runs a
253 // focus action when the fingers lift.
254 g.mode, g.snap = swipeDiscrete, newStepSwipe()
255 case g.horizontal && w.stashFocused() && !w.floatFocus && w.stashShown():
256 g.mode, g.ws, g.home = swipeStash, w, w.stashAt
257 g.ids = make([]WindowID, 0, len(w.Stash))
258 for _, f := range w.Stash {
259 g.ids = append(g.ids, f.ID)
260 }
261 w.stashView.motion = motion{}
262 g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand)
263 case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable():
264 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
265 w.view.motion = motion{}
266 g.snap = newSnapSwipe(float64(w.View)+w.view.off, float64(w.View), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.policy().content.span(w.Usable))))
267 case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil:
268 // A landing slide measured in an older list lands at once first.
269 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
270 m.stopSwitch()
271 }
272 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
273 axis := w.policy().workspace
274 if m.switchView.busy() && m.switchAxis != axis {
275 m.stopSwitch()
276 }
277 m.switchAxis = axis
278 g.list = slices.Clone(m.Workspaces)
279 m.switchView.motion, m.switchList = motion{}, nil
280 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
281 default:
282 g.mode, g.snap = swipeDiscrete, newStepSwipe()
283 }
284}
285
286// swipeEnd lands the swipe. shown reports that another workspace came on
287// screen. spawn is the workspace whose empty stash asked for its terminal
288// (see swipeStash): the caller starts it.
289func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) {
290 g := c.swipe
291 if g != nil && !c.hasScreen(g.screen) {
292 c.dropSwipe()
293 }
294 c.swipe = nil
295 if g == nil || !c.hasScreen(g.screen) {
296 return false, nil
297 }
298 now := c.now()
299 m := g.screen.mon
300 switch g.mode {
301 case swipeNavigation:
302 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
303 return false, nil
304 case swipeColumns:
305 w := g.ws
306 if g.columnsChanged(m) {
307 w.view.stop()
308 return false, nil
309 }
310 shown := float64(w.View) + w.view.off
311 target, velocity := g.snap.end(e.Cancelled, e.Time)
312 if !e.Cancelled {
313 view := int(math.Round(target))
314 focus := w.snapFocus(view, target >= shown)
315 if focus != w.Focus && w.policy().maximizeHides() {
316 w.unmaximize()
317 }
318 w.Focus = focus
319 w.View = view
320 // A column wider than the view aligns as focus moves align it.
321 w.scroll()
322 c.focusScreen = c.screenIndex(g.screen.name())
323 c.keyboard.takeBack()
324 }
325 w.view.off = shown - float64(w.View)
326 if c.animOn() {
327 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)
328 } else {
329 w.view.stop()
330 }
331 case swipeWorkspaces:
332 if g.listChanged(m) {
333 m.stopSwitch()
334 return false, nil
335 }
336 cur := float64(m.Active) + m.switchView.off
337 target, velocity := g.snap.end(e.Cancelled, e.Time)
338 idx := m.Active
339 if !e.Cancelled {
340 idx = int(math.Round(target))
341 }
342 // Focus may drop the empty workspace left behind and renumber the
343 // list: the slide keeps measuring in the list it began on.
344 off := cur - float64(idx)
345 if idx != m.Active {
346 m.Focus(idx)
347 shown = true
348 }
349 if !e.Cancelled {
350 c.focusScreen = c.screenIndex(g.screen.name())
351 c.keyboard.takeBack()
352 }
353 m.switchView.off, m.switchList = off, g.list
354 if c.animOn() {
355 m.switchView.motion = c.spring(m.switchSpring(off, velocity), now)
356 } else {
357 m.stopSwitch()
358 }
359 case swipeStash:
360 w := g.ws
361 if g.stashChanged(m) {
362 w.stashView.stop()
363 return false, nil
364 }
365 drawn := float64(w.stashAt) + w.stashView.off
366 target, velocity := g.snap.end(e.Cancelled, e.Time)
367 dir := 0
368 if !e.Cancelled {
369 dir = int(math.Round(target)) - w.stashAt
370 }
371 if c.cur() == g.screen && dir != 0 {
372 // The landing is the focus action's: the selection, the focus
373 // and the veils move as a bind moves them.
374 a := ActionFocusColumnRight
375 if dir < 0 {
376 a = ActionFocusColumnLeft
377 }
378 c.keyboard.takeBack()
379 c.applyAction(a)
380 }
381 w.stashView.off = drawn - float64(w.stashAt)
382 if c.animOn() {
383 w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, velocity), now)
384 } else {
385 w.stashView.stop()
386 }
387 // The stash never brings another workspace on screen.
388 return false, nil
389 case swipeOverview:
390 step := g.snap.step(e.Cancelled, e.Time)
391 if step == 0 {
392 return false, nil
393 }
394 if !m.ov.open && (step > 0 || m.Current().stashShown()) {
395 return c.swipeStash(g, now)
396 }
397 if (step < 0) == m.ov.open {
398 return false, nil
399 }
400 before := m.Current()
401 shots := c.snapshot(now)
402 m.ToggleOverview()
403 c.focusScreen = c.screenIndex(g.screen.name())
404 c.keyboard.takeBack()
405 c.transition(shots, now)
406 return m.Current() != before, nil
407 case swipeDiscrete:
408 step := g.snap.step(e.Cancelled, e.Time)
409 if step == 0 {
410 return false, nil
411 }
412 w := g.screen.mon.Current()
413 p := w.policy()
414 workspace := g.horizontal == (p.workspace == horizontalAxis)
415 a := ActionFocusColumnRight
416 if step < 0 {
417 a = ActionFocusColumnLeft
418 }
419 if workspace {
420 a = ActionFocusWorkspaceNext
421 if step < 0 {
422 a = ActionFocusWorkspacePrev
423 }
424 } else if p.wraps {
425 // A content swipe cannot escape the last band to another workspace.
426 if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
427 return false, nil
428 }
429 }
430 // Focus actions act on the focused screen: the swipe's, unless the
431 // pointer took the focus to another output meanwhile.
432 if c.cur() != g.screen {
433 return false, nil
434 }
435 if c.cur().mon.ov.open {
436 return false, nil
437 }
438 before := c.cur().mon.Current()
439 shots := c.snapshot(now)
440 c.keyboard.takeBack()
441 if p.wraps && !workspace {
442 w.focusBand(step)
443 } else {
444 c.applyAction(a)
445 }
446 c.transition(shots, now)
447 return c.cur().mon.Current() != before, nil
448 }
449 return shown, nil
450}
451
452// swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
453// while the stash is shown, with the overview closed, on the swipe's screen
454// unless the pointer took the focus to another output meanwhile. It reports
455// whether the stash showed or hid. An empty stash asking for its terminal
456// (stash.spawn-terminal) shows no workspace, as with the bind: spawn names the
457// workspace for the caller to start it in.
458func (c *Core) swipeStash(g *swipeGesture, now time.Time) (shown bool, spawn *Workspace) {
459 if c.cur() != g.screen {
460 return false, nil
461 }
462 w := g.screen.mon.Current()
463 hidden, over := w.stashHidden, w.stashOver
464 shots := c.snapshot(now)
465 c.keyboard.takeBack()
466 effect := c.applyAction(ActionToggleStashVisible)
467 c.transition(shots, now)
468 if effect.Spawn {
469 return false, w
470 }
471 return w.stashHidden != hidden || w.stashOver != over, nil
472}
473
474// swipedWorkspace is the workspace on the swipe's screen, or nil without
475// a swipe.
476func (c *Core) swipedWorkspace() *Workspace {
477 if c.swipe == nil {
478 return nil
479 }
480 return c.swipe.screen.mon.Current()
481}
482
483// dropSwipe lets go of the swipe in progress: a bind changed the workspace
484// on its screen. Its slide stops where the bind left the view; the rest of
485// the swipe is ignored, even if a later bind shows its workspace again.
486func (c *Core) dropSwipe() {
487 g := c.swipe
488 if g == nil {
489 return
490 }
491 switch g.mode {
492 case swipeColumns:
493 g.ws.view.stop()
494 case swipeWorkspaces:
495 g.screen.mon.stopSwitch()
496 case swipeStash:
497 g.ws.stashView.stop()
498 }
499 g.mode = swipeDropped
500}
501
502func (c *Core) hasScreen(s *screen) bool {
503 for _, v := range c.screens {
504 if v == s {
505 return true
506 }
507 }
508 return false
509}
510
511// animOn reports whether transitions run (animations = on).
512func (c *Core) animOn() bool { return c.cfg.Animations.On }
513
514// spring starts s at now, stretched by animations.speed (Slowdown). Every motion
515// core creates goes through it, except a retarget, which keeps the slowdown
516// of the motion it replaces.
517func (c *Core) spring(s spring, now time.Time) motion {
518 return newMotion(s, now, c.cfg.Animations.Slowdown)
519}
520
521// stopAnimations settles every running spring where it is going and stops
522// the frame timer: a session lock or a switch to animations = off. A swipe
523// in progress keeps its fingers' view: only running springs stop. Per-window
524// rect motions stop too.
525func (c *Core) stopAnimations() {
526 for _, sc := range c.screens {
527 sc.stopAnimations()
528 }
529 c.stopFrame()
530}
531
532// stopAnimations settles the screen's running springs where they are going.
533func (s *screen) stopAnimations() {
534 if s.mon.switchView.motion.on {
535 s.mon.stopSwitch()
536 }
537 for w := range s.mon.all() {
538 if w.view.motion.on {
539 w.view.stop()
540 }
541 if w.stashView.motion.on {
542 w.stashView.stop()
543 }
544 }
545 s.stopRects()
546}
547
548// animate moves the running springs to now; settled ones stop. A non-nil only
549// limits it to that screen (its own page flip); nil moves every screen.
550func (c *Core) animate(now time.Time, only *screen) {
551 for _, sc := range c.screens {
552 if only != nil && sc != only {
553 continue
554 }
555 m := sc.mon
556 if m.switchView.motion.on {
557 m.switchView.step(now)
558 if !m.switchView.motion.on {
559 m.switchList = nil
560 }
561 }
562 for w := range m.all() {
563 w.view.step(now)
564 w.stashView.step(now)
565 }
566 for id, rm := range sc.rects {
567 rm.advance(now)
568 if rm.on() {
569 sc.rects[id] = rm
570 } else {
571 delete(sc.rects, id)
572 }
573 }
574 }
575}
576
577// sliding reports whether a spring runs on the named output.
578func (c *Core) sliding(output string) bool {
579 i := c.screenIndex(output)
580 return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))
581}
582
583// animating reports whether a spring or the focus pulse runs on any output.
584func (c *Core) animating() bool {
585 if c.pulse.id != 0 {
586 return true
587 }
588 for _, sc := range c.screens {
589 if sc.springing() {
590 return true
591 }
592 }
593 return false
594}
595
596func (m *Monitor) springing() bool {
597 if m.switchView.motion.on {
598 return true
599 }
600 for w := range m.all() {
601 if w.view.motion.on || w.stashView.motion.on {
602 return true
603 }
604 }
605 return false
606}
607
608// frameFallback is how long core waits for a page flip before it moves a
609// running slide on its own (an output that stopped flipping).
610func (c *Core) frameFallback() time.Duration {
611 refresh := 60000
612 for _, sc := range c.screens {
613 if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
614 refresh = min(refresh, r)
615 }
616 }
617 return 2 * time.Second * 1000 / time.Duration(refresh)
618}
619
620// armFrame starts the fallback timer of a running slide. The timer is made
621// on the first arm and reset afterwards (Go 1.23 timers deliver nothing
622// stale after a Stop or Reset), so a frame costs no allocation.
623func (c *Core) armFrame() {
624 d := c.frameFallback()
625 if c.frameTimer == nil {
626 c.frameTimer = newPortTimer(c.opts.Clock, d)
627 } else {
628 c.frameTimer.Stop()
629 c.frameTimer.Reset(d)
630 }
631 c.frameC = c.frameTimer.C()
632}
633
634func (c *Core) stopFrame() {
635 if c.frameC != nil {
636 c.frameTimer.Stop()
637 }
638 c.frameC = nil
639}