detached f87efce

feat(core): keep three-finger swipes still when animations are off

commit
f87efcec112cbb238bc745423e206129a22314e1
author
brice · · 2026-10-05 06:06 UTC
parent
19a1e20
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +52 -20
M README.md +1 -1
@@ -18,7 +18,7 @@
1818
1919 I wanted a compositor I would never have to think about. It takes the newest Wayland protocols and kernel features as they land, gives games the whole GPU, and otherwise stays out of sight.
2020
21-It is small on purpose, and it will stay small. Drawing is kept to simple primitives: no blur, no shadows, no rounded corners, no themes. There is no built-in bar or wallpaper either; for those, bring your own: any layer-shell client works. Animations are experimental and off by default (`animations = on` turns them on). With them, windows and the view move with short springs (about 300 ms) when you scroll, switch workspaces, move columns or windows, resize them, drop a dragged tile or send a workspace to another monitor; windows fade in and out when they open, close or leave with the stash, the overview zooms its cards, and a brief pulse marks the window you just focused. They stay cheap: nothing runs while the screen is still, frames follow the display, only the region that moves is redrawn, apps are resized once, to their final size, and do not redraw during a transition, and nothing is copied or blurred. About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.
21+It is small on purpose, and it will stay small. Drawing is kept to simple primitives: no blur, no shadows, no rounded corners, no themes. There is no built-in bar or wallpaper either; for those, bring your own: any layer-shell client works. Animations are experimental and off by default (`animations = on` turns them on); off, every change is instant and touchpad swipes switch on release. With them, three-finger swipes follow the fingers, windows and the view move with short springs (about 300 ms) when you scroll, switch workspaces, move columns or windows, resize them, drop a dragged tile or send a workspace to another monitor; windows fade in and out when they open, close or leave with the stash, the overview zooms its cards, and a brief pulse marks the window you just focused. They stay cheap: nothing runs while the screen is still, frames follow the display, only the region that moves is redrawn, apps are resized once, to their final size, and do not redraw during a transition, and nothing is copied or blurred. About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.
2222
2323 ## OK, but why Go?
2424
@@ -18,7 +18,7 @@
18 18
19 I wanted a compositor I would never have to think about. It takes the newest Wayland protocols and kernel features as they land, gives games the whole GPU, and otherwise stays out of sight.19 I wanted a compositor I would never have to think about. It takes the newest Wayland protocols and kernel features as they land, gives games the whole GPU, and otherwise stays out of sight.
20 20
21-It is small on purpose, and it will stay small. Drawing is kept to simple primitives: no blur, no shadows, no rounded corners, no themes. There is no built-in bar or wallpaper either; for those, bring your own: any layer-shell client works. Animations are experimental and off by default (`animations = on` turns them on). With them, windows and the view move with short springs (about 300 ms) when you scroll, switch workspaces, move columns or windows, resize them, drop a dragged tile or send a workspace to another monitor; windows fade in and out when they open, close or leave with the stash, the overview zooms its cards, and a brief pulse marks the window you just focused. They stay cheap: nothing runs while the screen is still, frames follow the display, only the region that moves is redrawn, apps are resized once, to their final size, and do not redraw during a transition, and nothing is copied or blurred. About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.21+It is small on purpose, and it will stay small. Drawing is kept to simple primitives: no blur, no shadows, no rounded corners, no themes. There is no built-in bar or wallpaper either; for those, bring your own: any layer-shell client works. Animations are experimental and off by default (`animations = on` turns them on); off, every change is instant and touchpad swipes switch on release. With them, three-finger swipes follow the fingers, windows and the view move with short springs (about 300 ms) when you scroll, switch workspaces, move columns or windows, resize them, drop a dragged tile or send a workspace to another monitor; windows fade in and out when they open, close or leave with the stash, the overview zooms its cards, and a brief pulse marks the window you just focused. They stay cheap: nothing runs while the screen is still, frames follow the display, only the region that moves is redrawn, apps are resized once, to their final size, and do not redraw during a transition, and nothing is copied or blurred. About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.
22 22
23 ## OK, but why Go?23 ## OK, but why Go?
24 24
M docs/config.md +4 -2
@@ -44,7 +44,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4444 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |
4545 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
4646 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
47-| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |
47+| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |
4848 | `animations.slowdown` | `1` | Multiplies the duration of every spring (not the focus pulse), `0.1` to `10` |
4949 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
5050 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
@@ -136,7 +136,7 @@ bind.Cmd+p = workspace presentation
136136
137137 ## Touchpad
138138
139-A three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
139+With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
140140
141141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
142142 - Up or down slides to the next numbered workspace.
@@ -145,6 +145,8 @@ The view sticks a little near each step and catches up between them. Past the ne
145145
146146 With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147147
148+With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`.
149+
148150 Where the view cannot scroll (`fixed` overflow, the stash, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.
149151
150152 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
@@ -44,7 +44,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
44 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |44 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |
45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
47-| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |47+| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |
48 | `animations.slowdown` | `1` | Multiplies the duration of every spring (not the focus pulse), `0.1` to `10` |48 | `animations.slowdown` | `1` | Multiplies the duration of every spring (not the focus pulse), `0.1` to `10` |
49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
50 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |50 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
@@ -136,7 +136,7 @@ bind.Cmd+p = workspace presentation
136 136
137 ## Touchpad137 ## Touchpad
138 138
139-A three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:139+With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
140 140
141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
142 - Up or down slides to the next numbered workspace.142 - Up or down slides to the next numbered workspace.
@@ -145,6 +145,8 @@ The view sticks a little near each step and catches up between them. Past the ne
145 145
146 With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.146 With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147 147
148+With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`.
149+
148 Where the view cannot scroll (`fixed` overflow, the stash, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.150 Where the view cannot scroll (`fixed` overflow, the stash, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.
149 151
150 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.152 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
M examples/config +2 -1
@@ -75,7 +75,8 @@ focus.follow-move = off
7575 # Experimental, off by default.
7676 # on: windows and the view move with springs, windows fade in and out as
7777 # they open, close or leave with the stash, the overview zooms its cards;
78-# off: every change is instant.
78+# off: every change is instant and three-finger swipes act when the
79+# fingers lift.
7980 # slowdown multiplies every spring's duration (0.1 to 10).
8081 animations = off
8182 animations.slowdown = 1
@@ -75,7 +75,8 @@ focus.follow-move = off
75 # Experimental, off by default.75 # Experimental, off by default.
76 # on: windows and the view move with springs, windows fade in and out as76 # on: windows and the view move with springs, windows fade in and out as
77 # they open, close or leave with the stash, the overview zooms its cards;77 # they open, close or leave with the stash, the overview zooms its cards;
78-# off: every change is instant.78+# off: every change is instant and three-finger swipes act when the
79+# fingers lift.
79 # slowdown multiplies every spring's duration (0.1 to 10).80 # slowdown multiplies every spring's duration (0.1 to 10).
80 animations = off81 animations = off
81 animations.slowdown = 182 animations.slowdown = 1
M internal/core/animations_off_test.go +42 -15
@@ -9,12 +9,41 @@ import (
99
1010 func animationsOff(c *ports.Config) { c.Animations.On = false }
1111
12-func TestAnimationsOffFlickLandsAtOnce(t *testing.T) {
12+// stillMoves sends n swipe updates of (dx, dy) and expects no scene: with
13+// animations off the view does not follow the fingers.
14+func (r *swipeRig) stillMoves(t *testing.T, n int, dx, dy float64) {
15+ t.Helper()
16+ for range n {
17+ r.at += 8 * time.Millisecond
18+ r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
19+ }
20+ select {
21+ case s := <-r.scenes:
22+ t.Fatalf("a swipe update published a scene (%d windows): the view follows the fingers", len(s))
23+ case <-time.After(50 * time.Millisecond):
24+ }
25+}
26+
27+func focusedID(s ports.Scene) ports.WindowID {
28+ for _, w := range s.Windows {
29+ if w.Focused {
30+ return w.ID
31+ }
32+ }
33+ return 0
34+}
35+
36+func TestAnimationsOffColumnSwipeFocusesAtOnce(t *testing.T) {
1337 r := startSwipe(t, animationsOff)
14- threeColumns(t, r)
15- s := r.flick(t, 10, -40, 0)
16- if got, ok := rectOf(s, 1); !ok || got.X != 0 {
17- t.Fatalf("column 1 at %+v (shown %t) right after the lift, want the settled rect", got, ok)
38+ before := threeColumns(t, r)
39+ r.begin()
40+ r.stillMoves(t, 10, -40, 0)
41+ s := r.end(t, false)
42+ if got := focusedID(s); got != 2 {
43+ t.Fatalf("focused %d right after the lift, want 2", got)
44+ }
45+ if got, _ := rectOf(before, 3); got.X == 0 {
46+ t.Fatalf("bad setup: column 3 at %+v", got)
1847 }
1948 r.noFrameScene(t)
2049 }
@@ -23,9 +52,7 @@ func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) {
2352 r := startSwipe(t, animationsOff)
2453 r.workspaces(t)
2554 r.begin()
26- for range 9 {
27- r.move(t, 0, 30)
28- }
55+ r.stillMoves(t, 9, 0, 30)
2956 s := r.end(t, false)
3057 if _, ok := rectOf(s, 1); ok {
3158 t.Fatal("old workspace still shown right after the lift")
@@ -35,16 +62,16 @@ func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) {
3562 }
3663 }
3764
38-func TestAnimationsOffFingersStillFollowed(t *testing.T) {
65+func TestAnimationsOffSlowSwipeStays(t *testing.T) {
3966 r := startSwipe(t, animationsOff)
40- before, _ := rectOf(threeColumns(t, r), 2)
67+ before := threeColumns(t, r)
4168 r.begin()
42- r.input <- ports.SwipeUpdate{DX: -10, Time: r.at}
43- r.move(t, -10, 0)
44- s := r.move(t, -290, 0)
45- if got, _ := rectOf(s, 2); got.X-before.X != 200 {
46- t.Fatalf("column moved %d px with the fingers, want 200", got.X-before.X)
69+ r.stillMoves(t, 2, -10, 0)
70+ r.input <- ports.SwipeEnd{Time: r.at + time.Second}
71+ if s := scene(t, r.scenes); !s.SameAs(before) {
72+ t.Fatalf("a short slow swipe changed the scene: %+v, was %+v", s.Windows, before.Windows)
4773 }
74+ r.noFrameScene(t)
4875 }
4976
5077 func TestReloadToOffSettlesALanding(t *testing.T) {
@@ -9,12 +9,41 @@ import (
9 9
10 func animationsOff(c *ports.Config) { c.Animations.On = false }10 func animationsOff(c *ports.Config) { c.Animations.On = false }
11 11
12-func TestAnimationsOffFlickLandsAtOnce(t *testing.T) {12+// stillMoves sends n swipe updates of (dx, dy) and expects no scene: with
13+// animations off the view does not follow the fingers.
14+func (r *swipeRig) stillMoves(t *testing.T, n int, dx, dy float64) {
15+ t.Helper()
16+ for range n {
17+ r.at += 8 * time.Millisecond
18+ r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
19+ }
20+ select {
21+ case s := <-r.scenes:
22+ t.Fatalf("a swipe update published a scene (%d windows): the view follows the fingers", len(s))
23+ case <-time.After(50 * time.Millisecond):
24+ }
25+}
26+
27+func focusedID(s ports.Scene) ports.WindowID {
28+ for _, w := range s.Windows {
29+ if w.Focused {
30+ return w.ID
31+ }
32+ }
33+ return 0
34+}
35+
36+func TestAnimationsOffColumnSwipeFocusesAtOnce(t *testing.T) {
13 r := startSwipe(t, animationsOff)37 r := startSwipe(t, animationsOff)
14- threeColumns(t, r)38+ before := threeColumns(t, r)
15- s := r.flick(t, 10, -40, 0)39+ r.begin()
16- if got, ok := rectOf(s, 1); !ok || got.X != 0 {40+ r.stillMoves(t, 10, -40, 0)
17- t.Fatalf("column 1 at %+v (shown %t) right after the lift, want the settled rect", got, ok)41+ s := r.end(t, false)
42+ if got := focusedID(s); got != 2 {
43+ t.Fatalf("focused %d right after the lift, want 2", got)
44+ }
45+ if got, _ := rectOf(before, 3); got.X == 0 {
46+ t.Fatalf("bad setup: column 3 at %+v", got)
18 }47 }
19 r.noFrameScene(t)48 r.noFrameScene(t)
20 }49 }
@@ -23,9 +52,7 @@ func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) {
23 r := startSwipe(t, animationsOff)52 r := startSwipe(t, animationsOff)
24 r.workspaces(t)53 r.workspaces(t)
25 r.begin()54 r.begin()
26- for range 9 {55+ r.stillMoves(t, 9, 0, 30)
27- r.move(t, 0, 30)
28- }
29 s := r.end(t, false)56 s := r.end(t, false)
30 if _, ok := rectOf(s, 1); ok {57 if _, ok := rectOf(s, 1); ok {
31 t.Fatal("old workspace still shown right after the lift")58 t.Fatal("old workspace still shown right after the lift")
@@ -35,16 +62,16 @@ func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) {
35 }62 }
36 }63 }
37 64
38-func TestAnimationsOffFingersStillFollowed(t *testing.T) {65+func TestAnimationsOffSlowSwipeStays(t *testing.T) {
39 r := startSwipe(t, animationsOff)66 r := startSwipe(t, animationsOff)
40- before, _ := rectOf(threeColumns(t, r), 2)67+ before := threeColumns(t, r)
41 r.begin()68 r.begin()
42- r.input <- ports.SwipeUpdate{DX: -10, Time: r.at}69+ r.stillMoves(t, 2, -10, 0)
43- r.move(t, -10, 0)70+ r.input <- ports.SwipeEnd{Time: r.at + time.Second}
44- s := r.move(t, -290, 0)71+ if s := scene(t, r.scenes); !s.SameAs(before) {
45- if got, _ := rectOf(s, 2); got.X-before.X != 200 {72+ t.Fatalf("a short slow swipe changed the scene: %+v, was %+v", s.Windows, before.Windows)
46- t.Fatalf("column moved %d px with the fingers, want 200", got.X-before.X)
47 }73 }
74+ r.noFrameScene(t)
48 }75 }
49 76
50 func TestReloadToOffSettlesALanding(t *testing.T) {77 func TestReloadToOffSettlesALanding(t *testing.T) {
M internal/core/gesture.go +3 -1
@@ -168,8 +168,10 @@ func (c *Core) decide(g *swipeGesture) {
168168 case g.fingers == 4:
169169 // Four fingers sideways do nothing.
170170 g.mode = swipeDropped
171- case m.ov.open:
171+ case m.ov.open || !c.animOn():
172172 // 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.
173175 g.mode, g.snap = swipeDiscrete, newStepSwipe()
174176 case g.horizontal && w.slidable():
175177 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
@@ -168,8 +168,10 @@ func (c *Core) decide(g *swipeGesture) {
168 case g.fingers == 4:168 case g.fingers == 4:
169 // Four fingers sideways do nothing.169 // Four fingers sideways do nothing.
170 g.mode = swipeDropped170 g.mode = swipeDropped
171- case m.ov.open:171+ case m.ov.open || !c.animOn():
172 // The overview does not slide: the swipe moves its selection.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.
173 g.mode, g.snap = swipeDiscrete, newStepSwipe()175 g.mode, g.snap = swipeDiscrete, newStepSwipe()
174 case g.horizontal && w.slidable():176 case g.horizontal && w.slidable():
175 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()177 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()

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