feat/hints a471fa4

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 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			changed := w.Focus != focus
319			w.Focus = focus
320			w.View = view
321			// A column wider than the view aligns as focus moves align it.
322			w.scroll()
323			c.focusScreen = c.screenIndex(g.screen.name())
324			c.keyboard.takeBack()
325			if changed {
326				id, _ := w.Focused()
327				c.requestFlash(flashColumn, id)
328			}
329		}
330		w.view.off = shown - float64(w.View)
331		if c.animOn() {
332			w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)
333		} else {
334			w.view.stop()
335		}
336	case swipeWorkspaces:
337		if g.listChanged(m) {
338			m.stopSwitch()
339			return false, nil
340		}
341		cur := float64(m.Active) + m.switchView.off
342		target, velocity := g.snap.end(e.Cancelled, e.Time)
343		idx := m.Active
344		if !e.Cancelled {
345			idx = int(math.Round(target))
346		}
347		// Focus may drop the empty workspace left behind and renumber the
348		// list: the slide keeps measuring in the list it began on.
349		off := cur - float64(idx)
350		if idx != m.Active {
351			m.Focus(idx)
352			shown = true
353		}
354		if !e.Cancelled {
355			c.focusScreen = c.screenIndex(g.screen.name())
356			c.keyboard.takeBack()
357		}
358		m.switchView.off, m.switchList = off, g.list
359		if c.animOn() {
360			m.switchView.motion = c.spring(m.switchSpring(off, velocity), now)
361		} else {
362			m.stopSwitch()
363		}
364	case swipeStash:
365		w := g.ws
366		if g.stashChanged(m) {
367			w.stashView.stop()
368			return false, nil
369		}
370		drawn := float64(w.stashAt) + w.stashView.off
371		target, velocity := g.snap.end(e.Cancelled, e.Time)
372		dir := 0
373		if !e.Cancelled {
374			dir = int(math.Round(target)) - w.stashAt
375		}
376		if c.cur() == g.screen && dir != 0 {
377			// The landing is the focus action's: the selection, the focus
378			// and the veils move as a bind moves them.
379			a := ActionFocusColumnRight
380			if dir < 0 {
381				a = ActionFocusColumnLeft
382			}
383			c.keyboard.takeBack()
384			c.applyAction(a)
385		}
386		w.stashView.off = drawn - float64(w.stashAt)
387		if c.animOn() {
388			w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, velocity), now)
389		} else {
390			w.stashView.stop()
391		}
392		// The stash never brings another workspace on screen.
393		return false, nil
394	case swipeOverview:
395		step := g.snap.step(e.Cancelled, e.Time)
396		if step == 0 {
397			return false, nil
398		}
399		if !m.ov.open && (step > 0 || m.Current().stashShown()) {
400			return c.swipeStash(g, now)
401		}
402		if (step < 0) == m.ov.open {
403			return false, nil
404		}
405		before := m.Current()
406		shots := c.snapshot(now)
407		closing := m.ov.open
408		m.ToggleOverview()
409		c.focusScreen = c.screenIndex(g.screen.name())
410		c.keyboard.takeBack()
411		c.transition(shots, now)
412		if closing {
413			id, _ := m.Focused()
414			if id != 0 {
415				c.requestFlash(flashWindow, id)
416			}
417		}
418		return m.Current() != before, nil
419	case swipeDiscrete:
420		step := g.snap.step(e.Cancelled, e.Time)
421		if step == 0 {
422			return false, nil
423		}
424		w := g.screen.mon.Current()
425		p := w.policy()
426		workspace := g.horizontal == (p.workspace == horizontalAxis)
427		a := ActionFocusColumnRight
428		if step < 0 {
429			a = ActionFocusColumnLeft
430		}
431		if workspace {
432			a = ActionFocusWorkspaceNext
433			if step < 0 {
434				a = ActionFocusWorkspacePrev
435			}
436		} else if p.wraps {
437			// A content swipe cannot escape the last band to another workspace.
438			if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
439				return false, nil
440			}
441		}
442		// Focus actions act on the focused screen: the swipe's, unless the
443		// pointer took the focus to another output meanwhile.
444		if c.cur() != g.screen {
445			return false, nil
446		}
447		if c.cur().mon.ov.open {
448			return false, nil
449		}
450		before := c.cur().mon.Current()
451		shots := c.snapshot(now)
452		c.keyboard.takeBack()
453		if p.wraps && !workspace {
454			w.focusBand(step)
455		} else {
456			c.applyAction(a)
457		}
458		c.transition(shots, now)
459		return c.cur().mon.Current() != before, nil
460	}
461	return shown, nil
462}
463
464// swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
465// while the stash is shown, with the overview closed, on the swipe's screen
466// unless the pointer took the focus to another output meanwhile. It reports
467// whether the stash showed or hid. An empty stash asking for its terminal
468// (stash.spawn-terminal) shows no workspace, as with the bind: spawn names the
469// workspace for the caller to start it in.
470func (c *Core) swipeStash(g *swipeGesture, now time.Time) (shown bool, spawn *Workspace) {
471	if c.cur() != g.screen {
472		return false, nil
473	}
474	w := g.screen.mon.Current()
475	hidden, over := w.stashHidden, w.stashOver
476	shots := c.snapshot(now)
477	c.keyboard.takeBack()
478	effect := c.applyAction(ActionToggleStashVisible)
479	c.transition(shots, now)
480	if effect.Spawn {
481		return false, w
482	}
483	return w.stashHidden != hidden || w.stashOver != over, nil
484}
485
486// swipedWorkspace is the workspace on the swipe's screen, or nil without
487// a swipe.
488func (c *Core) swipedWorkspace() *Workspace {
489	if c.swipe == nil {
490		return nil
491	}
492	return c.swipe.screen.mon.Current()
493}
494
495// dropSwipe lets go of the swipe in progress: a bind changed the workspace
496// on its screen. Its slide stops where the bind left the view; the rest of
497// the swipe is ignored, even if a later bind shows its workspace again.
498func (c *Core) dropSwipe() {
499	g := c.swipe
500	if g == nil {
501		return
502	}
503	switch g.mode {
504	case swipeColumns:
505		g.ws.view.stop()
506	case swipeWorkspaces:
507		g.screen.mon.stopSwitch()
508	case swipeStash:
509		g.ws.stashView.stop()
510	}
511	g.mode = swipeDropped
512}
513
514func (c *Core) hasScreen(s *screen) bool {
515	for _, v := range c.screens {
516		if v == s {
517			return true
518		}
519	}
520	return false
521}
522
523// animOn reports whether transitions run (animations = on).
524func (c *Core) animOn() bool { return c.cfg.Animations.On }
525
526// spring starts s at now, stretched by animations.speed (Slowdown). Every motion
527// core creates goes through it, except a retarget, which keeps the slowdown
528// of the motion it replaces.
529func (c *Core) spring(s spring, now time.Time) motion {
530	return newMotion(s, now, c.cfg.Animations.Slowdown)
531}
532
533// stopAnimations settles every running spring where it is going and stops
534// the frame timer: a session lock or a switch to animations = off. A swipe
535// in progress keeps its fingers' view: only running springs stop. Per-window
536// rect motions stop too.
537func (c *Core) stopAnimations() {
538	for _, sc := range c.screens {
539		sc.stopAnimations()
540	}
541	c.stopFrame()
542}
543
544// stopAnimations settles the screen's running springs where they are going.
545func (s *screen) stopAnimations() {
546	if s.mon.switchView.motion.on {
547		s.mon.stopSwitch()
548	}
549	for w := range s.mon.all() {
550		if w.view.motion.on {
551			w.view.stop()
552		}
553		if w.stashView.motion.on {
554			w.stashView.stop()
555		}
556	}
557	s.stopRects()
558}
559
560// animate moves the running springs to now; settled ones stop. A non-nil only
561// limits it to that screen (its own page flip); nil moves every screen.
562func (c *Core) animate(now time.Time, only *screen) {
563	for _, sc := range c.screens {
564		if only != nil && sc != only {
565			continue
566		}
567		m := sc.mon
568		if m.switchView.motion.on {
569			m.switchView.step(now)
570			if !m.switchView.motion.on {
571				m.switchList = nil
572			}
573		}
574		for w := range m.all() {
575			w.view.step(now)
576			w.stashView.step(now)
577		}
578		for id, rm := range sc.rects {
579			rm.advance(now)
580			if rm.on() {
581				sc.rects[id] = rm
582			} else {
583				delete(sc.rects, id)
584			}
585		}
586	}
587}
588
589// sliding reports whether a spring or focus flash runs on the named output.
590func (c *Core) sliding(output string) bool {
591	i := c.screenIndex(output)
592	return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]))
593}
594
595// animating reports whether a spring or focus flash runs on any output.
596func (c *Core) animating() bool {
597	if c.flash.running != nil {
598		return true
599	}
600	for _, sc := range c.screens {
601		if sc.springing() {
602			return true
603		}
604	}
605	return false
606}
607
608func (m *Monitor) springing() bool {
609	if m.switchView.motion.on {
610		return true
611	}
612	for w := range m.all() {
613		if w.view.motion.on || w.stashView.motion.on {
614			return true
615		}
616	}
617	return false
618}
619
620// frameFallback is how long core waits for a page flip before it moves a
621// running slide on its own (an output that stopped flipping).
622func (c *Core) frameFallback() time.Duration {
623	refresh := 60000
624	for _, sc := range c.screens {
625		if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) {
626			refresh = min(refresh, r)
627		}
628	}
629	return 2 * time.Second * 1000 / time.Duration(refresh)
630}
631
632// armFrame starts the fallback timer of a running slide. The timer is made
633// on the first arm and reset afterwards (Go 1.23 timers deliver nothing
634// stale after a Stop or Reset), so a frame costs no allocation.
635func (c *Core) armFrame() {
636	d := c.frameFallback()
637	if c.frameTimer == nil {
638		c.frameTimer = newPortTimer(c.opts.Clock, d)
639	} else {
640		c.frameTimer.Stop()
641		c.frameTimer.Reset(d)
642	}
643	c.frameC = c.frameTimer.C()
644}
645
646func (c *Core) stopFrame() {
647	if c.frameC != nil {
648		c.frameTimer.Stop()
649	}
650	c.frameC = nil
651}

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 25

Tags 10

↑↓ moveenter openesc close