detached 3504227

feat(core): a four-finger swipe down shows or hides the stash

commit
350422796fede67ae09495735bca59c9561a071a
author
brice · · 2026-10-05 00:13 UTC
parent
6337cf2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +116 -2
M internal/core/gesture.go +26 -2
@@ -36,7 +36,8 @@ const (
3636 swipeWorkspaces
3737 swipeDiscrete
3838 // swipeOverview is a vertical four-finger swipe: up opens the
39- // overview, down closes it on the selection (niri's gesture).
39+ // overview, down closes it on the selection (niri's gesture); down
40+ // with the overview closed shows or hides the stash.
4041 swipeOverview
4142 // swipeDropped ignores the rest of a swipe whose workspace changed.
4243 swipeDropped
@@ -251,7 +252,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
251252 }
252253 case swipeOverview:
253254 step := g.snap.step(e.Cancelled, e.Time)
254- if step == 0 || (step < 0) == m.ov.open {
255+ if step == 0 {
256+ return false
257+ }
258+ if step > 0 && !m.ov.open {
259+ return c.swipeStash(g, now)
260+ }
261+ if (step < 0) == m.ov.open {
255262 return false
256263 }
257264 before := m.Current()
@@ -294,6 +301,23 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
294301 return shown
295302 }
296303
304+// swipeStash runs toggle-stash-visible for a four-finger swipe down with the
305+// overview closed, on the swipe's screen unless the pointer took the focus
306+// to another output meanwhile. It reports whether the stash showed or
307+// hid: an empty stash stays as it is.
308+func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {
309+ if c.cur() != g.screen {
310+ return false
311+ }
312+ w := g.screen.mon.Current()
313+ hidden, over := w.stashHidden, w.stashOver
314+ shots := c.snapshot(now)
315+ c.keyboard.takeBack()
316+ c.applyAction(ActionToggleStashVisible)
317+ c.transition(shots, now)
318+ return w.stashHidden != hidden || w.stashOver != over
319+}
320+
297321 // swipedWorkspace is the workspace on the swipe's screen, or nil without
298322 // a swipe.
299323 func (c *Core) swipedWorkspace() *Workspace {
@@ -36,7 +36,8 @@ const (
36 swipeWorkspaces36 swipeWorkspaces
37 swipeDiscrete37 swipeDiscrete
38 // swipeOverview is a vertical four-finger swipe: up opens the38 // swipeOverview is a vertical four-finger swipe: up opens the
39- // overview, down closes it on the selection (niri's gesture).39+ // overview, down closes it on the selection (niri's gesture); down
40+ // with the overview closed shows or hides the stash.
40 swipeOverview41 swipeOverview
41 // swipeDropped ignores the rest of a swipe whose workspace changed.42 // swipeDropped ignores the rest of a swipe whose workspace changed.
42 swipeDropped43 swipeDropped
@@ -251,7 +252,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
251 }252 }
252 case swipeOverview:253 case swipeOverview:
253 step := g.snap.step(e.Cancelled, e.Time)254 step := g.snap.step(e.Cancelled, e.Time)
254- if step == 0 || (step < 0) == m.ov.open {255+ if step == 0 {
256+ return false
257+ }
258+ if step > 0 && !m.ov.open {
259+ return c.swipeStash(g, now)
260+ }
261+ if (step < 0) == m.ov.open {
255 return false262 return false
256 }263 }
257 before := m.Current()264 before := m.Current()
@@ -294,6 +301,23 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
294 return shown301 return shown
295 }302 }
296 303
304+// swipeStash runs toggle-stash-visible for a four-finger swipe down with the
305+// overview closed, on the swipe's screen unless the pointer took the focus
306+// to another output meanwhile. It reports whether the stash showed or
307+// hid: an empty stash stays as it is.
308+func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {
309+ if c.cur() != g.screen {
310+ return false
311+ }
312+ w := g.screen.mon.Current()
313+ hidden, over := w.stashHidden, w.stashOver
314+ shots := c.snapshot(now)
315+ c.keyboard.takeBack()
316+ c.applyAction(ActionToggleStashVisible)
317+ c.transition(shots, now)
318+ return w.stashHidden != hidden || w.stashOver != over
319+}
320+
297 // swipedWorkspace is the workspace on the swipe's screen, or nil without321 // swipedWorkspace is the workspace on the swipe's screen, or nil without
298 // a swipe.322 // a swipe.
299 func (c *Core) swipedWorkspace() *Workspace {323 func (c *Core) swipedWorkspace() *Workspace {
M internal/core/swipe_test.go +90 -0
@@ -671,6 +671,96 @@ func TestFourFingerSwipeOverview(t *testing.T) {
671671 }
672672 }
673673
674+// four lifts a four-finger swipe of n updates of (dx, dy) after the
675+// fingers went down, and returns the scene right after the lift.
676+func (r *swipeRig) four(t *testing.T, dx, dy float64, n int) ports.Scene {
677+ t.Helper()
678+ r.at += time.Second
679+ r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
680+ for range n {
681+ r.at += 8 * time.Millisecond
682+ r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
683+ }
684+ return r.end(t, false)
685+}
686+
687+// stashedThird maps three windows and stashes the third (shown, focused),
688+// settled.
689+func stashedThird(t *testing.T, r *swipeRig) ports.Scene {
690+ t.Helper()
691+ threeColumns(t, r)
692+ s := r.key(t, "s", ports.ModAlt|ports.ModShift)[0]
693+ if r.cfg.Animations.On {
694+ s = r.settle(t)
695+ }
696+ return s
697+}
698+
699+// A four-finger swipe down with the overview closed hides the stash, and
700+// the next one shows it again with its windows fading in; the stash's
701+// windows stay where the swipe found them otherwise.
702+func TestFourFingerSwipeDownTogglesStash(t *testing.T) {
703+ r := startSwipe(t, nil)
704+ if !r.cfg.Animations.On {
705+ t.Fatal("animations are off in the rig")
706+ }
707+ if w := windowOf(t, stashedThird(t, r), 3); w.Hidden {
708+ t.Fatalf("setup: the stash is hidden: %+v", w)
709+ }
710+ r.four(t, 0, 40, 10)
711+ if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
712+ t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
713+ }
714+ if w := windowOf(t, r.settle(t), 3); !w.Hidden {
715+ t.Fatalf("swipe down left the stash shown: %+v", w)
716+ }
717+ r.four(t, 0, 40, 10)
718+ if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
719+ t.Fatalf("showing: window 3 hidden %t, fade %v, want it appearing", w.Hidden, w.Fade)
720+ }
721+ if w := windowOf(t, r.settle(t), 3); w.Hidden || w.Fade != 0 {
722+ t.Fatalf("second swipe down did not show the stash: %+v", w)
723+ }
724+}
725+
726+// With nothing in the stash a swipe down changes nothing: the scene is the
727+// same one, with no new Seq and no animation.
728+func TestFourFingerSwipeDownEmptyStash(t *testing.T) {
729+ r := startSwipe(t, nil)
730+ before := threeColumns(t, r)
731+ after := r.four(t, 0, 40, 10)
732+ if after.Seq != before.Seq || !after.SameAs(before) {
733+ t.Fatalf("empty stash: scene changed, seq %d then %d", before.Seq, after.Seq)
734+ }
735+ if _, ok := r.settleAll(t, r.outs...); ok {
736+ t.Fatal("empty stash: the swipe started an animation")
737+ }
738+}
739+
740+// With the overview open a swipe down closes it and does not touch the
741+// stash, shown or hidden.
742+func TestFourFingerSwipeDownInOverviewKeepsStash(t *testing.T) {
743+ for _, hidden := range []bool{false, true} {
744+ r := startSwipe(t, nil)
745+ stashedThird(t, r)
746+ if hidden {
747+ r.key(t, "s", ports.ModAlt)
748+ r.settle(t)
749+ }
750+ r.four(t, 0, -40, 10)
751+ r.settle(t)
752+ s := r.four(t, 0, 40, 10)
753+ for _, w := range s.Windows {
754+ if w.Preview > 0 {
755+ t.Fatalf("hidden=%v: swipe down left the overview open", hidden)
756+ }
757+ }
758+ if w := windowOf(t, r.settle(t), 3); w.Hidden != hidden {
759+ t.Fatalf("hidden=%v: window 3 hidden %t after the overview closed", hidden, w.Hidden)
760+ }
761+ }
762+}
763+
674764 // Opening the overview during a column swipe drops the swipe: the rest
675765 // of it neither slides the columns under the overview nor lands them,
676766 // also once the overview closed again (escape before the rest).
@@ -671,6 +671,96 @@ func TestFourFingerSwipeOverview(t *testing.T) {
671 }671 }
672 }672 }
673 673
674+// four lifts a four-finger swipe of n updates of (dx, dy) after the
675+// fingers went down, and returns the scene right after the lift.
676+func (r *swipeRig) four(t *testing.T, dx, dy float64, n int) ports.Scene {
677+ t.Helper()
678+ r.at += time.Second
679+ r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
680+ for range n {
681+ r.at += 8 * time.Millisecond
682+ r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
683+ }
684+ return r.end(t, false)
685+}
686+
687+// stashedThird maps three windows and stashes the third (shown, focused),
688+// settled.
689+func stashedThird(t *testing.T, r *swipeRig) ports.Scene {
690+ t.Helper()
691+ threeColumns(t, r)
692+ s := r.key(t, "s", ports.ModAlt|ports.ModShift)[0]
693+ if r.cfg.Animations.On {
694+ s = r.settle(t)
695+ }
696+ return s
697+}
698+
699+// A four-finger swipe down with the overview closed hides the stash, and
700+// the next one shows it again with its windows fading in; the stash's
701+// windows stay where the swipe found them otherwise.
702+func TestFourFingerSwipeDownTogglesStash(t *testing.T) {
703+ r := startSwipe(t, nil)
704+ if !r.cfg.Animations.On {
705+ t.Fatal("animations are off in the rig")
706+ }
707+ if w := windowOf(t, stashedThird(t, r), 3); w.Hidden {
708+ t.Fatalf("setup: the stash is hidden: %+v", w)
709+ }
710+ r.four(t, 0, 40, 10)
711+ if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
712+ t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
713+ }
714+ if w := windowOf(t, r.settle(t), 3); !w.Hidden {
715+ t.Fatalf("swipe down left the stash shown: %+v", w)
716+ }
717+ r.four(t, 0, 40, 10)
718+ if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
719+ t.Fatalf("showing: window 3 hidden %t, fade %v, want it appearing", w.Hidden, w.Fade)
720+ }
721+ if w := windowOf(t, r.settle(t), 3); w.Hidden || w.Fade != 0 {
722+ t.Fatalf("second swipe down did not show the stash: %+v", w)
723+ }
724+}
725+
726+// With nothing in the stash a swipe down changes nothing: the scene is the
727+// same one, with no new Seq and no animation.
728+func TestFourFingerSwipeDownEmptyStash(t *testing.T) {
729+ r := startSwipe(t, nil)
730+ before := threeColumns(t, r)
731+ after := r.four(t, 0, 40, 10)
732+ if after.Seq != before.Seq || !after.SameAs(before) {
733+ t.Fatalf("empty stash: scene changed, seq %d then %d", before.Seq, after.Seq)
734+ }
735+ if _, ok := r.settleAll(t, r.outs...); ok {
736+ t.Fatal("empty stash: the swipe started an animation")
737+ }
738+}
739+
740+// With the overview open a swipe down closes it and does not touch the
741+// stash, shown or hidden.
742+func TestFourFingerSwipeDownInOverviewKeepsStash(t *testing.T) {
743+ for _, hidden := range []bool{false, true} {
744+ r := startSwipe(t, nil)
745+ stashedThird(t, r)
746+ if hidden {
747+ r.key(t, "s", ports.ModAlt)
748+ r.settle(t)
749+ }
750+ r.four(t, 0, -40, 10)
751+ r.settle(t)
752+ s := r.four(t, 0, 40, 10)
753+ for _, w := range s.Windows {
754+ if w.Preview > 0 {
755+ t.Fatalf("hidden=%v: swipe down left the overview open", hidden)
756+ }
757+ }
758+ if w := windowOf(t, r.settle(t), 3); w.Hidden != hidden {
759+ t.Fatalf("hidden=%v: window 3 hidden %t after the overview closed", hidden, w.Hidden)
760+ }
761+ }
762+}
763+
674 // Opening the overview during a column swipe drops the swipe: the rest764 // Opening the overview during a column swipe drops the swipe: the rest
675 // of it neither slides the columns under the overview nor lands them,765 // of it neither slides the columns under the overview nor lands them,
676 // also once the overview closed again (escape before the rest).766 // also once the overview closed again (escape before the rest).

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 26

Tags 10

↑↓ moveenter openesc close