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).
40 swipeOverview
41 // swipeDropped ignores the rest of a swipe whose workspace changed.
42 swipeDropped
43)
44
45// swipeGesture is the swipe in progress, on the screen focused when it began.
46type swipeGesture struct {
47 screen *screen
48 fingers int
49 mode swipeMode
50 horizontal bool
51 cx, cy float64
52 // snap follows the movement along the chosen axis, natural scroll
53 // applied: positive scrolls right or down. Its view is absolute: the
54 // columns' x in pixels or the monitor's fractional workspace index,
55 // so a focus change during the swipe does not move the fingers' view.
56 // A discrete or overview swipe counts steps of -1, 0 or 1.
57 snap snapSwipe
58 // ws is the workspace whose columns scroll, or the active one when a
59 // workspace slide began.
60 ws *Workspace
61 // points are ws's column snap points when a column swipe began: the
62 // swipe ends if the layout no longer has them.
63 points []float64
64 // list is the numbered workspaces when a workspace slide began: the
65 // slide indexes it, so it ends if the list changes.
66 list []*Workspace
67 // opens is the monitor's overviewOpens when the swipe picked its
68 // mode: a slide the overview opened over is dropped, even once the
69 // overview closed again.
70 opens int
71}
72
73// listChanged reports whether the numbered workspaces or the active one
74// changed since a workspace slide began, or the overview opened over it.
75func (g *swipeGesture) listChanged(m *Monitor) bool {
76 return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)
77}
78
79// columnsChanged reports whether a column swipe lost its workspace: it is
80// no longer shown or slidable, its snap points moved (an output resize, a
81// panel, a window mapped or resized, other gaps), or the overview opened
82// over it.
83func (g *swipeGesture) columnsChanged(m *Monitor) bool {
84 return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
85}
86
87// swipeSign turns finger movement into view movement: natural scroll moves
88// the content with the fingers.
89func (c *Core) swipeSign() float64 {
90 if c.cfg.Touchpad.NaturalScroll {
91 return -1
92 }
93 return 1
94}
95
96// swipeBegin starts a swipe. Input streams one swipe at a time; one still
97// running (its end was lost) ends cancelled first: its slide settles back.
98func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
99 changed := false
100 if c.swipe != nil {
101 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
102 changed = true
103 }
104 c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
105 return changed
106}
107
108// swipeUpdate moves the swipe; it reports whether the scene changed.
109func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
110 g := c.swipe
111 if g == nil || !c.hasScreen(g.screen) {
112 c.swipe = nil
113 return false
114 }
115 if g.mode == swipeUndecided {
116 g.cx += u.DX
117 g.cy += u.DY
118 if g.cx*g.cx+g.cy*g.cy < swipeDecide*swipeDecide {
119 return false
120 }
121 c.decide(g)
122 }
123 d := u.DY
124 if g.horizontal {
125 d = u.DX
126 }
127 if g.mode == swipeOverview {
128 // Like niri, the overview gesture ignores natural scroll: the
129 // fingers going up open it.
130 g.snap.push(d, u.Time)
131 return false
132 }
133 g.snap.push(d*c.swipeSign(), u.Time)
134 m := g.screen.mon
135 switch g.mode {
136 case swipeColumns:
137 if g.columnsChanged(m) {
138 g.ws.stopSlide()
139 g.mode = swipeDropped
140 return true
141 }
142 g.ws.shift = g.snap.pos() - float64(g.ws.ViewX)
143 return true
144 case swipeWorkspaces:
145 if g.listChanged(m) {
146 m.stopSwitch()
147 g.mode = swipeDropped
148 return true
149 }
150 m.switchOff = g.snap.pos() - float64(m.Active)
151 return true
152 }
153 return false
154}
155
156// decide picks the swipe's axis and what it moves. A running slide stops
157// where it is: the fingers take it from there.
158func (c *Core) decide(g *swipeGesture) {
159 g.horizontal = math.Abs(g.cx) > math.Abs(g.cy)
160 m := g.screen.mon
161 g.opens = m.overviewOpens
162 w := m.Current()
163 switch {
164 case g.fingers == 4 && !g.horizontal:
165 g.mode, g.snap = swipeOverview, newStepSwipe()
166 case g.fingers == 4:
167 // Four fingers sideways do nothing.
168 g.mode = swipeDropped
169 case m.ov.open:
170 // The overview does not slide: the swipe moves its selection.
171 g.mode, g.snap = swipeDiscrete, newStepSwipe()
172 case g.horizontal && w.slidable():
173 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
174 w.motion = nil
175 g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
176 case !g.horizontal && m.shown == nil:
177 // A landing slide measured in an older list lands at once first.
178 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
179 m.stopSwitch()
180 }
181 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
182 g.list = slices.Clone(m.Workspaces)
183 m.switchMotion, m.switchList = nil, nil
184 g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
185 default:
186 g.mode, g.snap = swipeDiscrete, newStepSwipe()
187 }
188}
189
190// swipeEnd lands the swipe. shown reports that another workspace came on
191// screen.
192func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
193 g := c.swipe
194 c.swipe = nil
195 if g == nil || !c.hasScreen(g.screen) {
196 return false
197 }
198 now := c.now()
199 m := g.screen.mon
200 switch g.mode {
201 case swipeColumns:
202 w := g.ws
203 if g.columnsChanged(m) {
204 w.stopSlide()
205 return false
206 }
207 shown := float64(w.ViewX) + w.shift
208 target, velocity := g.snap.end(e.Cancelled, e.Time)
209 if !e.Cancelled {
210 view := int(math.Round(target))
211 w.Focus = w.snapFocus(view, target >= shown)
212 w.ViewX = view
213 // A column wider than the view aligns as focus moves align it.
214 w.scroll()
215 c.focusScreen = c.screenIndex(g.screen.name())
216 c.keyboard.takeBack()
217 }
218 w.shift = shown - float64(w.ViewX)
219 w.motion = newMotion(viewSpring(w.shift, velocity), now)
220 case swipeWorkspaces:
221 if g.listChanged(m) {
222 m.stopSwitch()
223 return false
224 }
225 cur := float64(m.Active) + m.switchOff
226 target, velocity := g.snap.end(e.Cancelled, e.Time)
227 idx := m.Active
228 if !e.Cancelled {
229 idx = int(math.Round(target))
230 }
231 // Focus may drop the empty workspace left behind and renumber the
232 // list: the slide keeps measuring in the list it began on.
233 off := cur - float64(idx)
234 if idx != m.Active {
235 m.Focus(idx)
236 shown = true
237 }
238 if !e.Cancelled {
239 c.focusScreen = c.screenIndex(g.screen.name())
240 c.keyboard.takeBack()
241 }
242 m.switchOff, m.switchList = off, g.list
243 m.switchMotion = newMotion(workspaceSpring(off, velocity), now)
244 case swipeOverview:
245 step := g.snap.step(e.Cancelled, e.Time)
246 if step == 0 || (step < 0) == m.ov.open {
247 return false
248 }
249 before := m.Current()
250 m.ToggleOverview()
251 c.focusScreen = c.screenIndex(g.screen.name())
252 c.keyboard.takeBack()
253 return m.Current() != before
254 case swipeDiscrete:
255 step := g.snap.step(e.Cancelled, e.Time)
256 if step == 0 {
257 return false
258 }
259 a := ActionFocusWorkspaceDown
260 switch {
261 case g.horizontal && step < 0:
262 a = ActionFocusColumnLeft
263 case g.horizontal:
264 a = ActionFocusColumnRight
265 case step < 0:
266 a = ActionFocusWorkspaceUp
267 }
268 // Focus actions act on the focused screen: the swipe's, unless the
269 // pointer took the focus to another output meanwhile.
270 if c.cur() != g.screen {
271 return false
272 }
273 before := c.cur().mon.Current()
274 c.keyboard.takeBack()
275 if mon := c.cur().mon; mon.ov.open {
276 mon.overviewFocus(a)
277 } else {
278 c.applyAction(a)
279 }
280 return c.cur().mon.Current() != before
281 }
282 return shown
283}
284
285// swipedWorkspace is the workspace on the swipe's screen, or nil without
286// a swipe.
287func (c *Core) swipedWorkspace() *Workspace {
288 if c.swipe == nil {
289 return nil
290 }
291 return c.swipe.screen.mon.Current()
292}
293
294// dropSwipe lets go of the swipe in progress: a bind changed the workspace
295// on its screen. Its slide stops where the bind left the view; the rest of
296// the swipe is ignored, even if a later bind shows its workspace again.
297func (c *Core) dropSwipe() {
298 g := c.swipe
299 if g == nil {
300 return
301 }
302 switch g.mode {
303 case swipeColumns:
304 g.ws.stopSlide()
305 case swipeWorkspaces:
306 g.screen.mon.stopSwitch()
307 }
308 g.mode = swipeDropped
309}
310
311func (c *Core) hasScreen(s *screen) bool {
312 for _, v := range c.screens {
313 if v == s {
314 return true
315 }
316 }
317 return false
318}
319
320// animate moves the running springs to now; settled ones stop.
321func (c *Core) animate(now time.Time) {
322 for _, sc := range c.screens {
323 m := sc.mon
324 if m.switchMotion != nil {
325 v, done := m.switchMotion.at(now)
326 m.switchOff = v
327 if done {
328 m.stopSwitch()
329 }
330 }
331 for w := range m.all() {
332 if w.motion != nil {
333 v, done := w.motion.at(now)
334 w.shift = v
335 if done {
336 w.stopSlide()
337 }
338 }
339 }
340 }
341}
342
343// sliding reports whether a spring runs on the named output.
344func (c *Core) sliding(output string) bool {
345 i := c.screenIndex(output)
346 return i >= 0 && (c.screens[i].mon.springing() || c.pulsing(c.screens[i]))
347}
348
349// animating reports whether a spring or the focus pulse runs on any output.
350func (c *Core) animating() bool {
351 if c.pulse.id != 0 {
352 return true
353 }
354 for _, sc := range c.screens {
355 if sc.mon.springing() {
356 return true
357 }
358 }
359 return false
360}
361
362func (m *Monitor) springing() bool {
363 if m.switchMotion != nil {
364 return true
365 }
366 for w := range m.all() {
367 if w.motion != nil {
368 return true
369 }
370 }
371 return false
372}
373
374// frameFallback is how long core waits for a page flip before it moves a
375// running slide on its own (an output that stopped flipping).
376func (c *Core) frameFallback() time.Duration {
377 refresh := 60000
378 for _, sc := range c.screens {
379 if r := sc.info.RefreshMilli; r > 0 && (sc.mon.springing() || c.pulsing(sc)) {
380 refresh = min(refresh, r)
381 }
382 }
383 return 2 * time.Second * 1000 / time.Duration(refresh)
384}
385
386// armFrame starts the fallback timer of a running slide.
387func (c *Core) armFrame() {
388 c.stopFrame()
389 c.frameC, c.frameStop = newTimer(c.ch.Clock, c.frameFallback())
390}
391
392func (c *Core) stopFrame() {
393 if c.frameStop != nil {
394 c.frameStop()
395 }
396 c.frameC, c.frameStop = nil, nil
397}