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). Where the
16// view cannot slide (fixed overflow, the stash, a float, a fullscreen
17// window, the overview), the swipe runs one focus action instead.
18
19const (
20 // swipeDecide is the touchpad distance before the swipe picks its axis
21 // (GNOME Shell's threshold).
22 swipeDecide = 16.0
23 // viewSwipeMovement is the touchpad distance that scrolls the columns by
24 // one usable width.
25 viewSwipeMovement = 1200.0
26 // workspaceSwipeMovement is the touchpad distance that slides one
27 // workspace.
28 workspaceSwipeMovement = 300.0
29)
30
31type swipeMode uint8
32
33const (
34 swipeUndecided swipeMode = iota
35 swipeColumns
36 swipeWorkspaces
37 swipeDiscrete
38 // swipeOverview is a vertical four-finger swipe: up opens the
39 // overview, down closes it on the selection (niri's gesture). With
40 // the overview closed, down shows or hides the stash, and up hides
41 // it when it is shown, else opens the overview.
42 swipeOverview
43 // swipeDropped ignores the rest of a swipe whose workspace changed.
44 swipeDropped
45)
46
47// swipeGesture is the swipe in progress, on the screen focused when it began.
48type swipeGesture struct {
49 screen *screen
50 fingers int
51 mode swipeMode
52 horizontal bool
53 cx, cy float64
54 // snap follows the movement along the chosen axis, natural scroll
55 // applied: positive scrolls right or down. Its view is absolute: the
56 // columns' x in pixels or the monitor's fractional workspace index,
57 // so a focus change during the swipe does not move the fingers' view.
58 // A discrete or overview swipe counts steps of -1, 0 or 1.
59 snap snapSwipe
60 // ws is the workspace whose columns scroll, or the active one when a
61 // workspace slide began.
62 ws *Workspace
63 // points are ws's column snap points when a column swipe began: the
64 // swipe ends if the layout no longer has them.
65 points []float64
66 // list is the numbered workspaces when a workspace slide began: the
67 // slide indexes it, so it ends if the list changes.
68 list []*Workspace
69 // opens is the monitor's overviewOpens when the swipe picked its
70 // mode: a slide the overview opened over is dropped, even once the
71 // overview closed again.
72 opens int
73}
74
75// listChanged reports whether the numbered workspaces or the active one
76// changed since a workspace slide began, or the overview opened over it.
77func (g *swipeGesture) listChanged(m *Monitor) bool {
78 return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)
79}
80
81// columnsChanged reports whether a column swipe lost its workspace: it is
82// no longer shown or slidable, its snap points moved (an output resize, a
83// panel, a window mapped or resized, other gaps), or the overview opened
84// over it.
85func (g *swipeGesture) columnsChanged(m *Monitor) bool {
86 return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
87}
88
89// swipeSign turns finger movement into view movement: natural scroll moves
90// the content with the fingers.
91func (c *Core) swipeSign() float64 {
92 if c.cfg.Touchpad.NaturalScroll {
93 return -1
94 }
95 return 1
96}
97
98// swipeBegin starts a swipe. Input streams one swipe at a time; one still
99// running (its end was lost) ends cancelled first: its slide settles back.
100func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
101 changed := false
102 if c.swipe != nil {
103 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
104 changed = true
105 }
106 c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
107 return changed
108}
109
110// swipeUpdate moves the swipe; it reports whether the scene changed.
111func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
112 g := c.swipe
113 if g == nil || !c.hasScreen(g.screen) {
114 c.swipe = nil
115 return false
116 }
117 if g.mode == swipeUndecided {
118 g.cx += u.DX
119 g.cy += u.DY
120 if g.cx*g.cx+g.cy*g.cy < swipeDecide*swipeDecide {
121 return false
122 }
123 c.decide(g)
124 }
125 d := u.DY
126 if g.horizontal {
127 d = u.DX
128 }
129 if g.mode == swipeOverview {
130 // Like niri, the overview gesture ignores natural scroll: the
131 // fingers going up open it.
132 g.snap.push(d, u.Time)
133 return false
134 }
135 g.snap.push(d*c.swipeSign(), u.Time)
136 m := g.screen.mon
137 switch g.mode {
138 case swipeColumns:
139 if g.columnsChanged(m) {
140 g.ws.stopSlide()
141 g.mode = swipeDropped
142 return true
143 }
144 g.ws.shift = g.snap.pos() - float64(g.ws.ViewX)
145 return true
146 case swipeWorkspaces:
147 if g.listChanged(m) {
148 m.stopSwitch()
149 g.mode = swipeDropped
150 return true
151 }
152 m.switchOff = g.snap.pos() - float64(m.Active)
153 return true
154 }
155 return false
156}
157
158// decide picks the swipe's axis and what it moves. A running slide stops
159// where it is: the fingers take it from there.
160func (c *Core) decide(g *swipeGesture) {
161 g.horizontal = math.Abs(g.cx) > math.Abs(g.cy)
162 m := g.screen.mon
163 g.opens = m.overviewOpens
164 w := m.Current()
165 switch {
166 case g.fingers == 4 && !g.horizontal:
167 g.mode, g.snap = swipeOverview, newStepSwipe()
168 case g.fingers == 4:
169 // Four fingers sideways do nothing.
170 g.mode = swipeDropped
171 case m.ov.open || !c.animOn():
172 // The overview does not slide: the swipe moves its selection.
173 // With animations off nothing slides either: the swipe runs a
174 // focus action when the fingers lift.
175 g.mode, g.snap = swipeDiscrete, newStepSwipe()
176 case g.horizontal && w.slidable():
177 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
178 w.motion = motion{}
179 g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
180 case !g.horizontal && m.shown == nil:
181 // A landing slide measured in an older list lands at once first.
182 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
183 m.stopSwitch()
184 }
185 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
186 g.list = slices.Clone(m.Workspaces)
187 m.switchMotion, m.switchList = motion{}, nil
188 g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
189 default:
190 g.mode, g.snap = swipeDiscrete, newStepSwipe()
191 }
192}
193
194// swipeEnd lands the swipe. shown reports that another workspace came on
195// screen.
196func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
197 g := c.swipe
198 c.swipe = nil
199 if g == nil || !c.hasScreen(g.screen) {
200 return false
201 }
202 now := c.now()
203 m := g.screen.mon
204 switch g.mode {
205 case swipeColumns:
206 w := g.ws
207 if g.columnsChanged(m) {
208 w.stopSlide()
209 return false
210 }
211 shown := float64(w.ViewX) + w.shift
212 target, velocity := g.snap.end(e.Cancelled, e.Time)
213 if !e.Cancelled {
214 view := int(math.Round(target))
215 w.Focus = w.snapFocus(view, target >= shown)
216 w.ViewX = view
217 // A column wider than the view aligns as focus moves align it.
218 w.scroll()
219 c.focusScreen = c.screenIndex(g.screen.name())
220 c.keyboard.takeBack()
221 }
222 w.shift = shown - float64(w.ViewX)
223 if c.animOn() {
224 w.motion = c.spring(viewSpring(w.shift, velocity), now)
225 } else {
226 w.stopSlide()
227 }
228 case swipeWorkspaces:
229 if g.listChanged(m) {
230 m.stopSwitch()
231 return false
232 }
233 cur := float64(m.Active) + m.switchOff
234 target, velocity := g.snap.end(e.Cancelled, e.Time)
235 idx := m.Active
236 if !e.Cancelled {
237 idx = int(math.Round(target))
238 }
239 // Focus may drop the empty workspace left behind and renumber the
240 // list: the slide keeps measuring in the list it began on.
241 off := cur - float64(idx)
242 if idx != m.Active {
243 m.Focus(idx)
244 shown = true
245 }
246 if !e.Cancelled {
247 c.focusScreen = c.screenIndex(g.screen.name())
248 c.keyboard.takeBack()
249 }
250 m.switchOff, m.switchList = off, g.list
251 if c.animOn() {
252 m.switchMotion = c.spring(m.switchSpring(off, velocity), now)
253 } else {
254 m.stopSwitch()
255 }
256 case swipeOverview:
257 step := g.snap.step(e.Cancelled, e.Time)
258 if step == 0 {
259 return false
260 }
261 if !m.ov.open && (step > 0 || m.Current().stashShown()) {
262 return c.swipeStash(g, now)
263 }
264 if (step < 0) == m.ov.open {
265 return false
266 }
267 before := m.Current()
268 shots := c.snapshot(now)
269 m.ToggleOverview()
270 c.focusScreen = c.screenIndex(g.screen.name())
271 c.keyboard.takeBack()
272 c.transition(shots, now)
273 return m.Current() != before
274 case swipeDiscrete:
275 step := g.snap.step(e.Cancelled, e.Time)
276 if step == 0 {
277 return false
278 }
279 a := ActionFocusWorkspaceDown
280 switch {
281 case g.horizontal && step < 0:
282 a = ActionFocusColumnLeft
283 case g.horizontal:
284 a = ActionFocusColumnRight
285 case step < 0:
286 a = ActionFocusWorkspaceUp
287 }
288 // Focus actions act on the focused screen: the swipe's, unless the
289 // pointer took the focus to another output meanwhile.
290 if c.cur() != g.screen {
291 return false
292 }
293 before := c.cur().mon.Current()
294 shots := c.snapshot(now)
295 c.keyboard.takeBack()
296 if mon := c.cur().mon; mon.ov.open {
297 mon.overviewFocus(a)
298 } else {
299 c.applyAction(a)
300 }
301 c.transition(shots, now)
302 return c.cur().mon.Current() != before
303 }
304 return shown
305}
306
307// swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
308// while the stash is shown, with the overview closed, on the swipe's screen
309// unless the pointer took the focus to another output meanwhile. It reports
310// whether the stash showed or hid: an empty stash stays as it is.
311func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {
312 if c.cur() != g.screen {
313 return false
314 }
315 w := g.screen.mon.Current()
316 hidden, over := w.stashHidden, w.stashOver
317 shots := c.snapshot(now)
318 c.keyboard.takeBack()
319 c.applyAction(ActionToggleStashVisible)
320 c.transition(shots, now)
321 return w.stashHidden != hidden || w.stashOver != over
322}
323
324// swipedWorkspace is the workspace on the swipe's screen, or nil without
325// a swipe.
326func (c *Core) swipedWorkspace() *Workspace {
327 if c.swipe == nil {
328 return nil
329 }
330 return c.swipe.screen.mon.Current()
331}
332
333// dropSwipe lets go of the swipe in progress: a bind changed the workspace
334// on its screen. Its slide stops where the bind left the view; the rest of
335// the swipe is ignored, even if a later bind shows its workspace again.
336func (c *Core) dropSwipe() {
337 g := c.swipe
338 if g == nil {
339 return
340 }
341 switch g.mode {
342 case swipeColumns:
343 g.ws.stopSlide()
344 case swipeWorkspaces:
345 g.screen.mon.stopSwitch()
346 }
347 g.mode = swipeDropped
348}
349
350func (c *Core) hasScreen(s *screen) bool {
351 for _, v := range c.screens {
352 if v == s {
353 return true
354 }
355 }
356 return false
357}
358
359// animOn reports whether transitions run (animations = on).
360func (c *Core) animOn() bool { return c.cfg.Animations.On }
361
362// spring starts s at now, stretched by animations.speed (Slowdown). Every motion
363// core creates goes through it, except a retarget, which keeps the slowdown
364// of the motion it replaces.
365func (c *Core) spring(s spring, now time.Time) motion {
366 return newMotion(s, now, c.cfg.Animations.Slowdown)
367}
368
369// stopAnimations settles every running spring where it is going and stops
370// the frame timer: a session lock or a switch to animations = off. A swipe
371// in progress keeps its fingers' view: only running springs stop. Per-window
372// rect motions stop too.
373func (c *Core) stopAnimations() {
374 for _, sc := range c.screens {
375 sc.stopAnimations()
376 }
377 c.stopFrame()
378}
379
380// stopAnimations settles the screen's running springs where they are going.
381func (s *screen) stopAnimations() {
382 if s.mon.switchMotion.on {
383 s.mon.stopSwitch()
384 }
385 for w := range s.mon.all() {
386 if w.motion.on {
387 w.stopSlide()
388 }
389 }
390 s.stopRects()
391}
392
393// animate moves the running springs to now; settled ones stop. A non-nil only
394// limits it to that screen (its own page flip); nil moves every screen.
395func (c *Core) animate(now time.Time, only *screen) {
396 for _, sc := range c.screens {
397 if only != nil && sc != only {
398 continue
399 }
400 m := sc.mon
401 if m.switchMotion.on {
402 v, done := m.switchMotion.at(now)
403 m.switchOff = v
404 if done {
405 m.stopSwitch()
406 }
407 }
408 for w := range m.all() {
409 if w.motion.on {
410 v, done := w.motion.at(now)
411 w.shift = v
412 if done {
413 w.stopSlide()
414 }
415 }
416 }
417 for id, rm := range sc.rects {
418 rm.advance(now)
419 if rm.on() {
420 sc.rects[id] = rm
421 } else {
422 delete(sc.rects, id)
423 }
424 }
425 }
426}
427
428// sliding reports whether a spring runs on the named output.
429func (c *Core) sliding(output string) bool {
430 i := c.screenIndex(output)
431 return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))
432}
433
434// animating reports whether a spring or the focus pulse runs on any output.
435func (c *Core) animating() bool {
436 if c.pulse.id != 0 {
437 return true
438 }
439 for _, sc := range c.screens {
440 if sc.springing() {
441 return true
442 }
443 }
444 return false
445}
446
447func (m *Monitor) springing() bool {
448 if m.switchMotion.on {
449 return true
450 }
451 for w := range m.all() {
452 if w.motion.on {
453 return true
454 }
455 }
456 return false
457}
458
459// frameFallback is how long core waits for a page flip before it moves a
460// running slide on its own (an output that stopped flipping).
461func (c *Core) frameFallback() time.Duration {
462 refresh := 60000
463 for _, sc := range c.screens {
464 if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
465 refresh = min(refresh, r)
466 }
467 }
468 return 2 * time.Second * 1000 / time.Duration(refresh)
469}
470
471// armFrame starts the fallback timer of a running slide. The timer is made
472// on the first arm and reset afterwards (Go 1.23 timers deliver nothing
473// stale after a Stop or Reset), so a frame costs no allocation.
474func (c *Core) armFrame() {
475 d := c.frameFallback()
476 if c.frameTimer == nil {
477 c.frameTimer = newPortTimer(c.ch.Clock, d)
478 } else {
479 c.frameTimer.Stop()
480 c.frameTimer.Reset(d)
481 }
482 c.frameC = c.frameTimer.C()
483}
484
485func (c *Core) stopFrame() {
486 if c.frameC != nil {
487 c.frameTimer.Stop()
488 }
489 c.frameC = nil
490}