detached d8dfa1d

feat(core): one creation and one stop path for animations, off lands instantly

commit
d8dfa1d39231a187b6bc57fbe5df1025dd29f81f
author
brice · · 2026-10-04 15:44 UTC
committer
brice · · 2026-10-04 19:08 UTC
parent
6e31b43
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +211 -10
A internal/core/animations_off_test.go +113 -0
@@ -0,0 +1,113 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func animationsOff(c *ports.Config) { c.Animations.On = false }
11+
12+func TestAnimationsOffFlickLandsAtOnce(t *testing.T) {
13+ 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)
18+ }
19+ r.noFrameScene(t)
20+}
21+
22+func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) {
23+ r := startSwipe(t, animationsOff)
24+ r.workspaces(t)
25+ r.begin()
26+ for range 9 {
27+ r.move(t, 0, 30)
28+ }
29+ s := r.end(t, false)
30+ if _, ok := rectOf(s, 1); ok {
31+ t.Fatal("old workspace still shown right after the lift")
32+ }
33+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
34+ t.Fatalf("workspace 2 at %+v (shown %t), want the settled rect", got, ok)
35+ }
36+}
37+
38+func TestAnimationsOffFingersStillFollowed(t *testing.T) {
39+ r := startSwipe(t, animationsOff)
40+ before, _ := rectOf(threeColumns(t, r), 2)
41+ 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)
47+ }
48+}
49+
50+func TestReloadToOffSettlesALanding(t *testing.T) {
51+ r := startSwipe(t, nil)
52+ threeColumns(t, r)
53+ s := r.flick(t, 10, -40, 0)
54+ if got, _ := rectOf(s, 1); got.X == 0 {
55+ t.Fatal("no landing slide to interrupt")
56+ }
57+ cfg := r.cfg
58+ cfg.Animations.On = false
59+ r.reload <- ports.ConfigChanged{Config: cfg}
60+ s = scene(t, r.scenes)
61+ if got, _ := rectOf(s, 1); got.X != 0 {
62+ t.Fatalf("column 1 at %d after the reload, want the settled rect", got.X)
63+ }
64+ r.noFrameScene(t)
65+}
66+
67+// noFrameScene sends page flips and expects no scene: no spring runs, so
68+// the flips have nothing to move and publish nothing.
69+func (r *swipeRig) noFrameScene(t *testing.T) {
70+ t.Helper()
71+ for range 2 {
72+ r.advance(16 * time.Millisecond)
73+ r.frames <- ports.OutputFrame{Output: wide.Name}
74+ }
75+ select {
76+ case s := <-r.scenes:
77+ t.Fatalf("a flip published a scene (%d windows): a spring still runs", len(s))
78+ case <-time.After(50 * time.Millisecond):
79+ }
80+}
81+
82+// landing flicks left and returns the rect of column 1 after each given
83+// time since the lift.
84+func landing(t *testing.T, slowdown float64, after ...time.Duration) []int {
85+ t.Helper()
86+ r := startSwipe(t, func(c *ports.Config) { c.Animations.Slowdown = slowdown })
87+ threeColumns(t, r)
88+ r.flick(t, 10, -40, 0)
89+ var xs []int
90+ var at time.Duration
91+ for _, d := range after {
92+ got, _ := rectOf(r.frame(t, d-at), 1)
93+ xs = append(xs, got.X)
94+ at = d
95+ }
96+ return xs
97+}
98+
99+func TestSlowdownStretchesALanding(t *testing.T) {
100+ // Critically damped at stiffness 800: settled after about 330 ms, 660 ms
101+ // with slowdown 2 (the exact doubling is checked on the motion).
102+ one := landing(t, 1, 100*time.Millisecond, 400*time.Millisecond)
103+ two := landing(t, 2, 100*time.Millisecond, 400*time.Millisecond, 700*time.Millisecond)
104+ if one[0] == 0 || two[0] == 0 {
105+ t.Fatalf("landing does not move after 100 ms: %v %v", one, two)
106+ }
107+ if one[1] != 0 || two[1] == 0 {
108+ t.Fatalf("at 400 ms: slowdown 1 at %d, want settled; slowdown 2 at %d, want moving", one[1], two[1])
109+ }
110+ if two[2] != 0 {
111+ t.Fatalf("slowdown 2 does not settle by 700 ms: %v", two)
112+ }
113+}
@@ -0,0 +1,113 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func animationsOff(c *ports.Config) { c.Animations.On = false }
11+
12+func TestAnimationsOffFlickLandsAtOnce(t *testing.T) {
13+ 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)
18+ }
19+ r.noFrameScene(t)
20+}
21+
22+func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) {
23+ r := startSwipe(t, animationsOff)
24+ r.workspaces(t)
25+ r.begin()
26+ for range 9 {
27+ r.move(t, 0, 30)
28+ }
29+ s := r.end(t, false)
30+ if _, ok := rectOf(s, 1); ok {
31+ t.Fatal("old workspace still shown right after the lift")
32+ }
33+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
34+ t.Fatalf("workspace 2 at %+v (shown %t), want the settled rect", got, ok)
35+ }
36+}
37+
38+func TestAnimationsOffFingersStillFollowed(t *testing.T) {
39+ r := startSwipe(t, animationsOff)
40+ before, _ := rectOf(threeColumns(t, r), 2)
41+ 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)
47+ }
48+}
49+
50+func TestReloadToOffSettlesALanding(t *testing.T) {
51+ r := startSwipe(t, nil)
52+ threeColumns(t, r)
53+ s := r.flick(t, 10, -40, 0)
54+ if got, _ := rectOf(s, 1); got.X == 0 {
55+ t.Fatal("no landing slide to interrupt")
56+ }
57+ cfg := r.cfg
58+ cfg.Animations.On = false
59+ r.reload <- ports.ConfigChanged{Config: cfg}
60+ s = scene(t, r.scenes)
61+ if got, _ := rectOf(s, 1); got.X != 0 {
62+ t.Fatalf("column 1 at %d after the reload, want the settled rect", got.X)
63+ }
64+ r.noFrameScene(t)
65+}
66+
67+// noFrameScene sends page flips and expects no scene: no spring runs, so
68+// the flips have nothing to move and publish nothing.
69+func (r *swipeRig) noFrameScene(t *testing.T) {
70+ t.Helper()
71+ for range 2 {
72+ r.advance(16 * time.Millisecond)
73+ r.frames <- ports.OutputFrame{Output: wide.Name}
74+ }
75+ select {
76+ case s := <-r.scenes:
77+ t.Fatalf("a flip published a scene (%d windows): a spring still runs", len(s))
78+ case <-time.After(50 * time.Millisecond):
79+ }
80+}
81+
82+// landing flicks left and returns the rect of column 1 after each given
83+// time since the lift.
84+func landing(t *testing.T, slowdown float64, after ...time.Duration) []int {
85+ t.Helper()
86+ r := startSwipe(t, func(c *ports.Config) { c.Animations.Slowdown = slowdown })
87+ threeColumns(t, r)
88+ r.flick(t, 10, -40, 0)
89+ var xs []int
90+ var at time.Duration
91+ for _, d := range after {
92+ got, _ := rectOf(r.frame(t, d-at), 1)
93+ xs = append(xs, got.X)
94+ at = d
95+ }
96+ return xs
97+}
98+
99+func TestSlowdownStretchesALanding(t *testing.T) {
100+ // Critically damped at stiffness 800: settled after about 330 ms, 660 ms
101+ // with slowdown 2 (the exact doubling is checked on the motion).
102+ one := landing(t, 1, 100*time.Millisecond, 400*time.Millisecond)
103+ two := landing(t, 2, 100*time.Millisecond, 400*time.Millisecond, 700*time.Millisecond)
104+ if one[0] == 0 || two[0] == 0 {
105+ t.Fatalf("landing does not move after 100 ms: %v %v", one, two)
106+ }
107+ if one[1] != 0 || two[1] == 0 {
108+ t.Fatalf("at 400 ms: slowdown 1 at %d, want settled; slowdown 2 at %d, want moving", one[1], two[1])
109+ }
110+ if two[2] != 0 {
111+ t.Fatalf("slowdown 2 does not settle by 700 ms: %v", two)
112+ }
113+}
A internal/core/animations_stop_test.go +35 -0
@@ -0,0 +1,35 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestStopAnimationsSettlesEverySpring(t *testing.T) {
9+ c, ic := indicatorCore(t)
10+ c.cfg.Animations.On = true
11+ m := c.cur().mon
12+ m.AddWindow(1)
13+ w := m.Current()
14+ w.shift, w.motion = 40, c.spring(viewSpring(40, 0), ic.now)
15+ m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now)
16+ c.armFrame()
17+ if !c.animating() || c.frameC == nil {
18+ t.Fatal("no running spring to stop")
19+ }
20+ c.stopAnimations()
21+ if c.animating() || w.motion.on || w.shift != 0 || m.switchMotion.on || m.switchOff != 0 || c.frameC != nil {
22+ t.Fatalf("springs left: shift %v, switch %v, motion %v %v, timer %v", w.shift, m.switchOff, w.motion.on, m.switchMotion.on, c.frameC != nil)
23+ }
24+}
25+
26+func TestSpringAppliesSlowdown(t *testing.T) {
27+ c, ic := indicatorCore(t)
28+ c.cfg.Animations.Slowdown = 1
29+ one := c.spring(viewSpring(40, 0), ic.now)
30+ c.cfg.Animations.Slowdown = 2
31+ two := c.spring(viewSpring(40, 0), ic.now)
32+ if d := two.end - 2*one.end; d < -time.Microsecond || d > time.Microsecond {
33+ t.Fatalf("slowdown 2 ends at %v, slowdown 1 at %v", two.end, one.end)
34+ }
35+}
@@ -0,0 +1,35 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestStopAnimationsSettlesEverySpring(t *testing.T) {
9+ c, ic := indicatorCore(t)
10+ c.cfg.Animations.On = true
11+ m := c.cur().mon
12+ m.AddWindow(1)
13+ w := m.Current()
14+ w.shift, w.motion = 40, c.spring(viewSpring(40, 0), ic.now)
15+ m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now)
16+ c.armFrame()
17+ if !c.animating() || c.frameC == nil {
18+ t.Fatal("no running spring to stop")
19+ }
20+ c.stopAnimations()
21+ if c.animating() || w.motion.on || w.shift != 0 || m.switchMotion.on || m.switchOff != 0 || c.frameC != nil {
22+ t.Fatalf("springs left: shift %v, switch %v, motion %v %v, timer %v", w.shift, m.switchOff, w.motion.on, m.switchMotion.on, c.frameC != nil)
23+ }
24+}
25+
26+func TestSpringAppliesSlowdown(t *testing.T) {
27+ c, ic := indicatorCore(t)
28+ c.cfg.Animations.Slowdown = 1
29+ one := c.spring(viewSpring(40, 0), ic.now)
30+ c.cfg.Animations.Slowdown = 2
31+ two := c.spring(viewSpring(40, 0), ic.now)
32+ if d := two.end - 2*one.end; d < -time.Microsecond || d > time.Microsecond {
33+ t.Fatalf("slowdown 2 ends at %v, slowdown 1 at %v", two.end, one.end)
34+ }
35+}
M internal/core/core.go +3 -0
@@ -1228,6 +1228,9 @@ func (c *Core) Run(ctx context.Context) error {
12281228 }
12291229 continue
12301230 }
1231+ if !c.animOn() {
1232+ c.stopAnimations()
1233+ }
12311234 // A rotation can shrink the layout under a still pointer.
12321235 c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
12331236 if c.workspaceVisible(ctx, false) != nil {
@@ -1228,6 +1228,9 @@ func (c *Core) Run(ctx context.Context) error {
1228 }1228 }
1229 continue1229 continue
1230 }1230 }
1231+ if !c.animOn() {
1232+ c.stopAnimations()
1233+ }
1231 // A rotation can shrink the layout under a still pointer.1234 // A rotation can shrink the layout under a still pointer.
1232 c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)1235 c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1233 if c.workspaceVisible(ctx, false) != nil {1236 if c.workspaceVisible(ctx, false) != nil {
M internal/core/gesture.go +38 -2
@@ -216,7 +216,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
216216 c.keyboard.takeBack()
217217 }
218218 w.shift = shown - float64(w.ViewX)
219- w.motion = newMotion(viewSpring(w.shift, velocity), now, 1)
219+ if c.animOn() {
220+ w.motion = c.spring(viewSpring(w.shift, velocity), now)
221+ } else {
222+ w.stopSlide()
223+ }
220224 case swipeWorkspaces:
221225 if g.listChanged(m) {
222226 m.stopSwitch()
@@ -240,7 +244,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
240244 c.keyboard.takeBack()
241245 }
242246 m.switchOff, m.switchList = off, g.list
243- m.switchMotion = newMotion(workspaceSpring(off, velocity), now, 1)
247+ if c.animOn() {
248+ m.switchMotion = c.spring(workspaceSpring(off, velocity), now)
249+ } else {
250+ m.stopSwitch()
251+ }
244252 case swipeOverview:
245253 step := g.snap.step(e.Cancelled, e.Time)
246254 if step == 0 || (step < 0) == m.ov.open {
@@ -317,6 +325,34 @@ func (c *Core) hasScreen(s *screen) bool {
317325 return false
318326 }
319327
328+// animOn reports whether transitions run (animations = on).
329+func (c *Core) animOn() bool { return c.cfg.Animations.On }
330+
331+// spring starts s at now, slowed down by animations.slowdown. Every motion
332+// core creates goes through it, except a retarget, which keeps the slowdown
333+// of the motion it replaces.
334+func (c *Core) spring(s spring, now time.Time) motion {
335+ return newMotion(s, now, c.cfg.Animations.Slowdown)
336+}
337+
338+// stopAnimations settles every running spring where it is going and stops
339+// the frame timer: a session lock or a switch to animations = off. A swipe
340+// in progress keeps its fingers' view: only running springs stop. Per-window
341+// rect motions stop here too once they exist.
342+func (c *Core) stopAnimations() {
343+ for _, sc := range c.screens {
344+ if sc.mon.switchMotion.on {
345+ sc.mon.stopSwitch()
346+ }
347+ for w := range sc.mon.all() {
348+ if w.motion.on {
349+ w.stopSlide()
350+ }
351+ }
352+ }
353+ c.stopFrame()
354+}
355+
320356 // animate moves the running springs to now; settled ones stop.
321357 func (c *Core) animate(now time.Time) {
322358 for _, sc := range c.screens {
@@ -216,7 +216,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
216 c.keyboard.takeBack()216 c.keyboard.takeBack()
217 }217 }
218 w.shift = shown - float64(w.ViewX)218 w.shift = shown - float64(w.ViewX)
219- w.motion = newMotion(viewSpring(w.shift, velocity), now, 1)219+ if c.animOn() {
220+ w.motion = c.spring(viewSpring(w.shift, velocity), now)
221+ } else {
222+ w.stopSlide()
223+ }
220 case swipeWorkspaces:224 case swipeWorkspaces:
221 if g.listChanged(m) {225 if g.listChanged(m) {
222 m.stopSwitch()226 m.stopSwitch()
@@ -240,7 +244,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
240 c.keyboard.takeBack()244 c.keyboard.takeBack()
241 }245 }
242 m.switchOff, m.switchList = off, g.list246 m.switchOff, m.switchList = off, g.list
243- m.switchMotion = newMotion(workspaceSpring(off, velocity), now, 1)247+ if c.animOn() {
248+ m.switchMotion = c.spring(workspaceSpring(off, velocity), now)
249+ } else {
250+ m.stopSwitch()
251+ }
244 case swipeOverview:252 case swipeOverview:
245 step := g.snap.step(e.Cancelled, e.Time)253 step := g.snap.step(e.Cancelled, e.Time)
246 if step == 0 || (step < 0) == m.ov.open {254 if step == 0 || (step < 0) == m.ov.open {
@@ -317,6 +325,34 @@ func (c *Core) hasScreen(s *screen) bool {
317 return false325 return false
318 }326 }
319 327
328+// animOn reports whether transitions run (animations = on).
329+func (c *Core) animOn() bool { return c.cfg.Animations.On }
330+
331+// spring starts s at now, slowed down by animations.slowdown. Every motion
332+// core creates goes through it, except a retarget, which keeps the slowdown
333+// of the motion it replaces.
334+func (c *Core) spring(s spring, now time.Time) motion {
335+ return newMotion(s, now, c.cfg.Animations.Slowdown)
336+}
337+
338+// stopAnimations settles every running spring where it is going and stops
339+// the frame timer: a session lock or a switch to animations = off. A swipe
340+// in progress keeps its fingers' view: only running springs stop. Per-window
341+// rect motions stop here too once they exist.
342+func (c *Core) stopAnimations() {
343+ for _, sc := range c.screens {
344+ if sc.mon.switchMotion.on {
345+ sc.mon.stopSwitch()
346+ }
347+ for w := range sc.mon.all() {
348+ if w.motion.on {
349+ w.stopSlide()
350+ }
351+ }
352+ }
353+ c.stopFrame()
354+}
355+
320 // animate moves the running springs to now; settled ones stop.356 // animate moves the running springs to now; settled ones stop.
321 func (c *Core) animate(now time.Time) {357 func (c *Core) animate(now time.Time) {
322 for _, sc := range c.screens {358 for _, sc := range c.screens {
M internal/core/pulse.go +3 -1
@@ -43,7 +43,9 @@ type focusPulse struct {
4343 timerStop func() bool
4444 }
4545
46-func (c *Core) pulseOn() bool { return c.cfg.Focus.Animation == ports.FocusAnimationPulse }
46+func (c *Core) pulseOn() bool {
47+ return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse
48+}
4749
4850 // pulseFocus follows the window focus. held is false while something else
4951 // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
@@ -43,7 +43,9 @@ type focusPulse struct {
43 timerStop func() bool43 timerStop func() bool
44 }44 }
45 45
46-func (c *Core) pulseOn() bool { return c.cfg.Focus.Animation == ports.FocusAnimationPulse }46+func (c *Core) pulseOn() bool {
47+ return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse
48+}
47 49
48 // pulseFocus follows the window focus. held is false while something else50 // pulseFocus follows the window focus. held is false while something else
49 // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse51 // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
M internal/core/pulse_test.go +18 -0
@@ -12,6 +12,7 @@ import (
1212 func pulseCore(t *testing.T) (*Core, *indicatorClock) {
1313 t.Helper()
1414 c, ic := indicatorCore(t)
15+ c.cfg.Animations.On = true
1516 c.cfg.Focus.Animation = ports.FocusAnimationPulse
1617 c.cfg.Focus.Strength = 0.05
1718 c.cur().mon.AddWindow(1)
@@ -259,3 +260,20 @@ func TestFocusPulseOff(t *testing.T) {
259260 t.Fatal("enabling the pulse pulses the focused window")
260261 }
261262 }
263+
264+func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
265+ c, ic := pulseCore(t)
266+ c.cfg.Animations.On = false
267+ if c.pulseOn() {
268+ t.Fatal("pulse on with animations off")
269+ }
270+ c.cur().mon.Current().FocusID(1)
271+ indicatorScene(t, c)
272+ if c.pulse.timerC != nil || c.pulse.id != 0 {
273+ t.Fatal("pulse armed with animations off")
274+ }
275+ ic.now = ic.now.Add(pulseSettle)
276+ if c.pulseTick() || c.animating() {
277+ t.Fatal("pulse with animations off")
278+ }
279+}
@@ -12,6 +12,7 @@ import (
12 func pulseCore(t *testing.T) (*Core, *indicatorClock) {12 func pulseCore(t *testing.T) (*Core, *indicatorClock) {
13 t.Helper()13 t.Helper()
14 c, ic := indicatorCore(t)14 c, ic := indicatorCore(t)
15+ c.cfg.Animations.On = true
15 c.cfg.Focus.Animation = ports.FocusAnimationPulse16 c.cfg.Focus.Animation = ports.FocusAnimationPulse
16 c.cfg.Focus.Strength = 0.0517 c.cfg.Focus.Strength = 0.05
17 c.cur().mon.AddWindow(1)18 c.cur().mon.AddWindow(1)
@@ -259,3 +260,20 @@ func TestFocusPulseOff(t *testing.T) {
259 t.Fatal("enabling the pulse pulses the focused window")260 t.Fatal("enabling the pulse pulses the focused window")
260 }261 }
261 }262 }
263+
264+func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
265+ c, ic := pulseCore(t)
266+ c.cfg.Animations.On = false
267+ if c.pulseOn() {
268+ t.Fatal("pulse on with animations off")
269+ }
270+ c.cur().mon.Current().FocusID(1)
271+ indicatorScene(t, c)
272+ if c.pulse.timerC != nil || c.pulse.id != 0 {
273+ t.Fatal("pulse armed with animations off")
274+ }
275+ ic.now = ic.now.Add(pulseSettle)
276+ if c.pulseTick() || c.animating() {
277+ t.Fatal("pulse with animations off")
278+ }
279+}
M internal/core/security.go +1 -7
@@ -33,13 +33,7 @@ func (c *Core) syncSecurity() bool {
3333 }
3434 c.dropSwipe()
3535 c.swipe = nil
36- for _, sc := range c.screens {
37- sc.mon.stopSwitch()
38- for w := range sc.mon.all() {
39- w.stopSlide()
40- }
41- }
42- c.stopFrame()
36+ c.stopAnimations()
4337 c.stopPulse()
4438 if state.Protected {
4539 c.dropCaptureSessions()
@@ -33,13 +33,7 @@ func (c *Core) syncSecurity() bool {
33 }33 }
34 c.dropSwipe()34 c.dropSwipe()
35 c.swipe = nil35 c.swipe = nil
36- for _, sc := range c.screens {36+ c.stopAnimations()
37- sc.mon.stopSwitch()
38- for w := range sc.mon.all() {
39- w.stopSlide()
40- }
41- }
42- c.stopFrame()
43 c.stopPulse()37 c.stopPulse()
44 if state.Protected {38 if state.Protected {
45 c.dropCaptureSessions()39 c.dropCaptureSessions()

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 23

Tags 10

↑↓ moveenter openesc close