gesture.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close