v0.4.0 b6be3a8

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). 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}

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