v0.3.0 cf370a0

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

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