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 || !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 return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
102}
103
104// stashChanged reports whether a stash swipe lost its stash: it is no
105// longer shown or focused, its windows or selection changed, or the
106// overview opened over it.
107func (g *swipeGesture) stashChanged(m *Monitor) bool {
108 w := g.ws
109 return m.overviewOpens != g.opens || m.Current() != w || !w.stashFocused() || w.floatFocus || !w.stashShown() || w.stashAt != g.home ||
110 !slices.EqualFunc(w.Stash, g.ids, func(f Float, id WindowID) bool { return f.ID == id })
111}
112
113// swipeSign turns finger movement into view movement: natural scroll moves
114// the content with the fingers.
115func (c *Core) swipeSign() float64 {
116 if c.cfg.Touchpad.NaturalScroll {
117 return -1
118 }
119 return 1
120}
121
122// swipeBegin starts a swipe. Input streams one swipe at a time; one still
123// running (its end was lost) ends cancelled first: its slide settles back.
124func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
125 changed := false
126 if c.swipe != nil {
127 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
128 changed = true
129 }
130 c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
131 if b.Fingers == 3 && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
132 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
133 c.swipe.mode = swipeNavigation
134 }
135 return changed
136}
137
138// swipeUpdate moves the swipe; it reports whether the scene changed.
139func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
140 g := c.swipe
141 if g == nil {
142 return false
143 }
144 if !c.hasScreen(g.screen) {
145 // Its screen went: let go of what it moved, or that stays shifted.
146 c.dropSwipe()
147 c.swipe = nil
148 return false
149 }
150 if g.mode == swipeNavigation {
151 m := g.screen.mon
152 if !m.ov.open || c.cur() != g.screen || c.overviewKeyboardTaken() {
153 g.mode = swipeDropped
154 return false
155 }
156 now := c.now()
157 shots := c.snapshot(now)
158 changed := m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger,
159 Horizontal: ports.ScrollAxis{Set: true, Value: u.DX * c.swipeSign()},
160 Vertical: ports.ScrollAxis{Set: true, Value: u.DY * c.swipeSign()}})
161 if changed {
162 c.keyboard.takeBack()
163 c.transition(shots, now)
164 }
165 return changed
166 }
167 if g.mode == swipeUndecided {
168 g.cx += u.DX
169 g.cy += u.DY
170 if g.cx*g.cx+g.cy*g.cy < swipeDecide*swipeDecide {
171 return false
172 }
173 c.decide(g)
174 }
175 d := u.DY
176 if g.horizontal {
177 d = u.DX
178 }
179 if g.mode == swipeOverview {
180 // Like niri, the overview gesture ignores natural scroll: the
181 // fingers going up open it.
182 g.snap.push(d, u.Time)
183 return false
184 }
185 g.snap.push(d*c.swipeSign(), u.Time)
186 m := g.screen.mon
187 switch g.mode {
188 case swipeColumns:
189 if g.columnsChanged(m) {
190 g.ws.view.stop()
191 g.mode = swipeDropped
192 return true
193 }
194 g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX)
195 return true
196 case swipeWorkspaces:
197 if g.listChanged(m) {
198 m.stopSwitch()
199 g.mode = swipeDropped
200 return true
201 }
202 m.switchView.off = g.snap.pos() - float64(m.Active)
203 return true
204 case swipeStash:
205 if g.stashChanged(m) {
206 g.ws.stashView.stop()
207 g.mode = swipeDropped
208 return true
209 }
210 g.ws.stashView.off = g.snap.pos() - float64(g.ws.stashAt)
211 return true
212 }
213 return false
214}
215
216// decide picks the swipe's axis and what it moves. A running slide stops
217// where it is: the fingers take it from there.
218func (c *Core) decide(g *swipeGesture) {
219 g.horizontal = math.Abs(g.cx) > math.Abs(g.cy)
220 m := g.screen.mon
221 g.opens = m.overviewOpens
222 w := m.Current()
223 switch {
224 case g.fingers == 4 && !g.horizontal:
225 g.mode, g.snap = swipeOverview, newStepSwipe()
226 case g.fingers == 4:
227 // Four fingers sideways do nothing.
228 g.mode = swipeDropped
229 case m.ov.open:
230 // Overview navigation is chosen at begin, never on release.
231 g.mode = swipeDropped
232 case !c.animOn():
233 // With animations off nothing slides: the swipe runs a
234 // focus action when the fingers lift.
235 g.mode, g.snap = swipeDiscrete, newStepSwipe()
236 case g.horizontal && w.stashFocused() && !w.floatFocus && w.stashShown():
237 g.mode, g.ws, g.home = swipeStash, w, w.stashAt
238 g.ids = make([]WindowID, 0, len(w.Stash))
239 for _, f := range w.Stash {
240 g.ids = append(g.ids, f.ID)
241 }
242 w.stashView.motion = motion{}
243 g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand)
244 case g.horizontal && w.slidable():
245 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
246 w.view.motion = motion{}
247 g.snap = newSnapSwipe(float64(w.ViewX)+w.view.off, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
248 case !g.horizontal && m.shown == nil:
249 // A landing slide measured in an older list lands at once first.
250 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
251 m.stopSwitch()
252 }
253 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
254 g.list = slices.Clone(m.Workspaces)
255 m.switchView.motion, m.switchList = motion{}, nil
256 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
257 default:
258 g.mode, g.snap = swipeDiscrete, newStepSwipe()
259 }
260}
261
262// swipeEnd lands the swipe. shown reports that another workspace came on
263// screen.
264func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
265 g := c.swipe
266 if g != nil && !c.hasScreen(g.screen) {
267 c.dropSwipe()
268 }
269 c.swipe = nil
270 if g == nil || !c.hasScreen(g.screen) {
271 return false
272 }
273 now := c.now()
274 m := g.screen.mon
275 switch g.mode {
276 case swipeNavigation:
277 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
278 return false
279 case swipeColumns:
280 w := g.ws
281 if g.columnsChanged(m) {
282 w.view.stop()
283 return false
284 }
285 shown := float64(w.ViewX) + w.view.off
286 target, velocity := g.snap.end(e.Cancelled, e.Time)
287 if !e.Cancelled {
288 view := int(math.Round(target))
289 w.Focus = w.snapFocus(view, target >= shown)
290 w.ViewX = view
291 // A column wider than the view aligns as focus moves align it.
292 w.scroll()
293 c.focusScreen = c.screenIndex(g.screen.name())
294 c.keyboard.takeBack()
295 }
296 w.view.off = shown - float64(w.ViewX)
297 if c.animOn() {
298 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)
299 } else {
300 w.view.stop()
301 }
302 case swipeWorkspaces:
303 if g.listChanged(m) {
304 m.stopSwitch()
305 return false
306 }
307 cur := float64(m.Active) + m.switchView.off
308 target, velocity := g.snap.end(e.Cancelled, e.Time)
309 idx := m.Active
310 if !e.Cancelled {
311 idx = int(math.Round(target))
312 }
313 // Focus may drop the empty workspace left behind and renumber the
314 // list: the slide keeps measuring in the list it began on.
315 off := cur - float64(idx)
316 if idx != m.Active {
317 m.Focus(idx)
318 shown = true
319 }
320 if !e.Cancelled {
321 c.focusScreen = c.screenIndex(g.screen.name())
322 c.keyboard.takeBack()
323 }
324 m.switchView.off, m.switchList = off, g.list
325 if c.animOn() {
326 m.switchView.motion = c.spring(m.switchSpring(off, velocity), now)
327 } else {
328 m.stopSwitch()
329 }
330 case swipeStash:
331 w := g.ws
332 if g.stashChanged(m) {
333 w.stashView.stop()
334 return false
335 }
336 drawn := float64(w.stashAt) + w.stashView.off
337 target, velocity := g.snap.end(e.Cancelled, e.Time)
338 dir := 0
339 if !e.Cancelled {
340 dir = int(math.Round(target)) - w.stashAt
341 }
342 if c.cur() == g.screen && dir != 0 {
343 // The landing is the focus action's: the selection, the focus
344 // and the veils move as a bind moves them.
345 a := ActionFocusColumnRight
346 if dir < 0 {
347 a = ActionFocusColumnLeft
348 }
349 c.keyboard.takeBack()
350 c.applyAction(a)
351 }
352 w.stashView.off = drawn - float64(w.stashAt)
353 if c.animOn() {
354 w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, velocity), now)
355 } else {
356 w.stashView.stop()
357 }
358 // The stash never brings another workspace on screen.
359 return false
360 case swipeOverview:
361 step := g.snap.step(e.Cancelled, e.Time)
362 if step == 0 {
363 return false
364 }
365 if !m.ov.open && (step > 0 || m.Current().stashShown()) {
366 return c.swipeStash(g, now)
367 }
368 if (step < 0) == m.ov.open {
369 return false
370 }
371 before := m.Current()
372 shots := c.snapshot(now)
373 m.ToggleOverview()
374 c.focusScreen = c.screenIndex(g.screen.name())
375 c.keyboard.takeBack()
376 c.transition(shots, now)
377 return m.Current() != before
378 case swipeDiscrete:
379 step := g.snap.step(e.Cancelled, e.Time)
380 if step == 0 {
381 return false
382 }
383 a := ActionFocusWorkspaceDown
384 switch {
385 case g.horizontal && step < 0:
386 a = ActionFocusColumnLeft
387 case g.horizontal:
388 a = ActionFocusColumnRight
389 case step < 0:
390 a = ActionFocusWorkspaceUp
391 }
392 // Focus actions act on the focused screen: the swipe's, unless the
393 // pointer took the focus to another output meanwhile.
394 if c.cur() != g.screen {
395 return false
396 }
397 if c.cur().mon.ov.open {
398 return false
399 }
400 before := c.cur().mon.Current()
401 shots := c.snapshot(now)
402 c.keyboard.takeBack()
403 c.applyAction(a)
404 c.transition(shots, now)
405 return c.cur().mon.Current() != before
406 }
407 return shown
408}
409
410// swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
411// while the stash is shown, with the overview closed, on the swipe's screen
412// unless the pointer took the focus to another output meanwhile. It reports
413// whether the stash showed or hid: an empty stash stays as it is.
414func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {
415 if c.cur() != g.screen {
416 return false
417 }
418 w := g.screen.mon.Current()
419 hidden, over := w.stashHidden, w.stashOver
420 shots := c.snapshot(now)
421 c.keyboard.takeBack()
422 c.applyAction(ActionToggleStashVisible)
423 c.transition(shots, now)
424 return w.stashHidden != hidden || w.stashOver != over
425}
426
427// swipedWorkspace is the workspace on the swipe's screen, or nil without
428// a swipe.
429func (c *Core) swipedWorkspace() *Workspace {
430 if c.swipe == nil {
431 return nil
432 }
433 return c.swipe.screen.mon.Current()
434}
435
436// dropSwipe lets go of the swipe in progress: a bind changed the workspace
437// on its screen. Its slide stops where the bind left the view; the rest of
438// the swipe is ignored, even if a later bind shows its workspace again.
439func (c *Core) dropSwipe() {
440 g := c.swipe
441 if g == nil {
442 return
443 }
444 switch g.mode {
445 case swipeColumns:
446 g.ws.view.stop()
447 case swipeWorkspaces:
448 g.screen.mon.stopSwitch()
449 case swipeStash:
450 g.ws.stashView.stop()
451 }
452 g.mode = swipeDropped
453}
454
455func (c *Core) hasScreen(s *screen) bool {
456 for _, v := range c.screens {
457 if v == s {
458 return true
459 }
460 }
461 return false
462}
463
464// animOn reports whether transitions run (animations = on).
465func (c *Core) animOn() bool { return c.cfg.Animations.On }
466
467// spring starts s at now, stretched by animations.speed (Slowdown). Every motion
468// core creates goes through it, except a retarget, which keeps the slowdown
469// of the motion it replaces.
470func (c *Core) spring(s spring, now time.Time) motion {
471 return newMotion(s, now, c.cfg.Animations.Slowdown)
472}
473
474// stopAnimations settles every running spring where it is going and stops
475// the frame timer: a session lock or a switch to animations = off. A swipe
476// in progress keeps its fingers' view: only running springs stop. Per-window
477// rect motions stop too.
478func (c *Core) stopAnimations() {
479 for _, sc := range c.screens {
480 sc.stopAnimations()
481 }
482 c.stopFrame()
483}
484
485// stopAnimations settles the screen's running springs where they are going.
486func (s *screen) stopAnimations() {
487 if s.mon.switchView.motion.on {
488 s.mon.stopSwitch()
489 }
490 for w := range s.mon.all() {
491 if w.view.motion.on {
492 w.view.stop()
493 }
494 if w.stashView.motion.on {
495 w.stashView.stop()
496 }
497 }
498 s.stopRects()
499}
500
501// animate moves the running springs to now; settled ones stop. A non-nil only
502// limits it to that screen (its own page flip); nil moves every screen.
503func (c *Core) animate(now time.Time, only *screen) {
504 for _, sc := range c.screens {
505 if only != nil && sc != only {
506 continue
507 }
508 m := sc.mon
509 if m.switchView.motion.on {
510 m.switchView.step(now)
511 if !m.switchView.motion.on {
512 m.switchList = nil
513 }
514 }
515 for w := range m.all() {
516 w.view.step(now)
517 w.stashView.step(now)
518 }
519 for id, rm := range sc.rects {
520 rm.advance(now)
521 if rm.on() {
522 sc.rects[id] = rm
523 } else {
524 delete(sc.rects, id)
525 }
526 }
527 }
528}
529
530// sliding reports whether a spring runs on the named output.
531func (c *Core) sliding(output string) bool {
532 i := c.screenIndex(output)
533 return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))
534}
535
536// animating reports whether a spring or the focus pulse runs on any output.
537func (c *Core) animating() bool {
538 if c.pulse.id != 0 {
539 return true
540 }
541 for _, sc := range c.screens {
542 if sc.springing() {
543 return true
544 }
545 }
546 return false
547}
548
549func (m *Monitor) springing() bool {
550 if m.switchView.motion.on {
551 return true
552 }
553 for w := range m.all() {
554 if w.view.motion.on || w.stashView.motion.on {
555 return true
556 }
557 }
558 return false
559}
560
561// frameFallback is how long core waits for a page flip before it moves a
562// running slide on its own (an output that stopped flipping).
563func (c *Core) frameFallback() time.Duration {
564 refresh := 60000
565 for _, sc := range c.screens {
566 if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
567 refresh = min(refresh, r)
568 }
569 }
570 return 2 * time.Second * 1000 / time.Duration(refresh)
571}
572
573// armFrame starts the fallback timer of a running slide. The timer is made
574// on the first arm and reset afterwards (Go 1.23 timers deliver nothing
575// stale after a Stop or Reset), so a frame costs no allocation.
576func (c *Core) armFrame() {
577 d := c.frameFallback()
578 if c.frameTimer == nil {
579 c.frameTimer = newPortTimer(c.ch.Clock, d)
580 } else {
581 c.frameTimer.Stop()
582 c.frameTimer.Reset(d)
583 }
584 c.frameC = c.frameTimer.C()
585}
586
587func (c *Core) stopFrame() {
588 if c.frameC != nil {
589 c.frameTimer.Stop()
590 }
591 c.frameC = nil
592}