detached d1d5df8

Merge branch 'fix/vrr-cursor-commit': carry cursor moves on game frames under VRR

commit
d1d5df802ba7aaa6e94f5ad121fc437d88e8fb50
author
brice · · 2026-09-28 15:39 UTC
merge of
7b60e95 + ed0aede · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +109 -2
M docs/performance.md +3 -0
@@ -6,6 +6,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
66
77 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
88 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9+- **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
910 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1011
1112 ## Guards
@@ -31,6 +32,8 @@ GODEBUG=gctrace=1 neferwl --backend=drm
3132
3233 `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.
3334
35+`slow flip` log entries (flip over 20 ms) carry `frame` (false: cursor or VRR state commit) and `vrr`; `input stats` shows coalesced pointer motion and the longest wait for core.
36+
3437 ## Reference: 4K HDR tiled video playback
3538
3639 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
@@ -6,6 +6,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
6 6
7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
8 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.8 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9+- **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
9 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.10 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
10 11
11 ## Guards12 ## Guards
@@ -31,6 +32,8 @@ GODEBUG=gctrace=1 neferwl --backend=drm
31 32
32 `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.33 `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.
33 34
35+`slow flip` log entries (flip over 20 ms) carry `frame` (false: cursor or VRR state commit) and `vrr`; `input stats` shows coalesced pointer motion and the longest wait for core.
36+
34 ## Reference: 4K HDR tiled video playback37 ## Reference: 4K HDR tiled video playback
35 38
36 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).39 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
M internal/adapters/drm/output.go +39 -2
@@ -94,6 +94,10 @@ type Output struct {
9494 vrrOn bool
9595 vrrGame bool
9696 composedSince time.Time
97+ // lastFrame is when the last frame was committed. cursorHeld: a cursor
98+ // move waits for the next frame (see cursorWaits).
99+ lastFrame time.Time
100+ cursorHeld bool
97101 // wantOff is the latest Scene.Off: a client turned the display off.
98102 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
99103 // follows wantOff, so a display turned off never lights up.
@@ -604,6 +608,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
604608 if !async {
605609 o.overlayOn = ov.buf
606610 }
611+ o.lastFrame, o.cursorHeld = time.Now(), false
607612 f.frame, f.async = true, async
608613 f.fences = dupFences(fence, ov.acquire)
609614 o.setAsync(async)
@@ -623,10 +628,17 @@ func (o *Output) cursorRefused() bool {
623628 func (o *Output) commitState(vrr bool) error {
624629 req := &atomicReq{}
625630 cur := cursorState{}
631+ // A move back to the applied state holds nothing.
632+ o.cursorHeld = false
626633 if o.cursor != nil {
627634 cur = o.cursor.desired()
628635 if cur != o.cursor.applied {
629- o.cursor.props(req, o.crtc, cur)
636+ if o.cursorWaits(time.Now()) {
637+ o.cursorHeld = true
638+ cur = o.cursor.applied
639+ } else {
640+ o.cursor.props(req, o.crtc, cur)
641+ }
630642 }
631643 }
632644 if vrr != o.vrrOn {
@@ -656,6 +668,18 @@ func (o *Output) commitState(vrr bool) error {
656668 return nil
657669 }
658670
671+// cursorMinInterval bounds how long a held cursor move waits for a frame.
672+const cursorMinInterval = time.Second / 24
673+
674+// cursorWaits reports whether a cursor move rides on the next frame
675+// instead of its own commit. Under VRR a cursor-only commit refreshes the
676+// panel at its slowest rate, which delays the game's next frame by up to
677+// one such refresh. A game that stops drawing still gets the cursor at
678+// cursorMinInterval.
679+func (o *Output) cursorWaits(now time.Time) bool {
680+ return o.vrrOn && o.vrrGame && now.Sub(o.lastFrame) < cursorMinInterval
681+}
682+
659683 func boolValue(b bool) uint64 {
660684 if b {
661685 return 1
@@ -857,11 +881,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
857881 // guarantees a stopped/reset timer cannot deliver its old value.
858882 retryTimer := time.NewTimer(time.Hour)
859883 vrrTimer := time.NewTimer(time.Hour)
884+ cursorTimer := time.NewTimer(time.Hour)
860885 o.frame.startTimer()
861886 retryTimer.Stop()
862887 vrrTimer.Stop()
888+ cursorTimer.Stop()
863889 defer retryTimer.Stop()
864890 defer vrrTimer.Stop()
891+ defer cursorTimer.Stop()
865892 defer o.frame.stopTimer()
866893 // seen is the latest content Seq per window. It is reported only while
867894 // no frame is in flight: then the GPU has finished every frame that
@@ -898,6 +925,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
898925 } else {
899926 vrrTimer.Stop()
900927 }
928+ // A held cursor move commits alone if no frame comes in time.
929+ var cursorDue <-chan time.Time
930+ if enabled && o.cursorHeld && !o.frame.pendingCommit() {
931+ cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame)))
932+ cursorDue = cursorTimer.C
933+ } else {
934+ cursorTimer.Stop()
935+ }
901936 // A commit whose event never comes must not stop the output.
902937 stuck := o.frame.wait(enabled)
903938 stateDirty := false
@@ -920,6 +955,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
920955 stateDirty = true
921956 case <-vrrOff:
922957 stateDirty = true
958+ case <-cursorDue:
959+ stateDirty = true
923960 case <-cursorWake:
924961 stateDirty = true
925962 case <-ctx.Done():
@@ -1149,7 +1186,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
11491186 return false
11501187 }
11511188 if age := time.Since(start); age > 20*time.Millisecond {
1152- o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip")
1189+ o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip")
11531190 }
11541191 if o.cursor != nil {
11551192 o.cursor.landed()
@@ -94,6 +94,10 @@ type Output struct {
94 vrrOn bool94 vrrOn bool
95 vrrGame bool95 vrrGame bool
96 composedSince time.Time96 composedSince time.Time
97+ // lastFrame is when the last frame was committed. cursorHeld: a cursor
98+ // move waits for the next frame (see cursorWaits).
99+ lastFrame time.Time
100+ cursorHeld bool
97 // wantOff is the latest Scene.Off: a client turned the display off.101 // wantOff is the latest Scene.Off: a client turned the display off.
98 // off: the CRTC is inactive for it. Every modeset (resume, recovery)102 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
99 // follows wantOff, so a display turned off never lights up.103 // follows wantOff, so a display turned off never lights up.
@@ -604,6 +608,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
604 if !async {608 if !async {
605 o.overlayOn = ov.buf609 o.overlayOn = ov.buf
606 }610 }
611+ o.lastFrame, o.cursorHeld = time.Now(), false
607 f.frame, f.async = true, async612 f.frame, f.async = true, async
608 f.fences = dupFences(fence, ov.acquire)613 f.fences = dupFences(fence, ov.acquire)
609 o.setAsync(async)614 o.setAsync(async)
@@ -623,10 +628,17 @@ func (o *Output) cursorRefused() bool {
623 func (o *Output) commitState(vrr bool) error {628 func (o *Output) commitState(vrr bool) error {
624 req := &atomicReq{}629 req := &atomicReq{}
625 cur := cursorState{}630 cur := cursorState{}
631+ // A move back to the applied state holds nothing.
632+ o.cursorHeld = false
626 if o.cursor != nil {633 if o.cursor != nil {
627 cur = o.cursor.desired()634 cur = o.cursor.desired()
628 if cur != o.cursor.applied {635 if cur != o.cursor.applied {
629- o.cursor.props(req, o.crtc, cur)636+ if o.cursorWaits(time.Now()) {
637+ o.cursorHeld = true
638+ cur = o.cursor.applied
639+ } else {
640+ o.cursor.props(req, o.crtc, cur)
641+ }
630 }642 }
631 }643 }
632 if vrr != o.vrrOn {644 if vrr != o.vrrOn {
@@ -656,6 +668,18 @@ func (o *Output) commitState(vrr bool) error {
656 return nil668 return nil
657 }669 }
658 670
671+// cursorMinInterval bounds how long a held cursor move waits for a frame.
672+const cursorMinInterval = time.Second / 24
673+
674+// cursorWaits reports whether a cursor move rides on the next frame
675+// instead of its own commit. Under VRR a cursor-only commit refreshes the
676+// panel at its slowest rate, which delays the game's next frame by up to
677+// one such refresh. A game that stops drawing still gets the cursor at
678+// cursorMinInterval.
679+func (o *Output) cursorWaits(now time.Time) bool {
680+ return o.vrrOn && o.vrrGame && now.Sub(o.lastFrame) < cursorMinInterval
681+}
682+
659 func boolValue(b bool) uint64 {683 func boolValue(b bool) uint64 {
660 if b {684 if b {
661 return 1685 return 1
@@ -857,11 +881,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
857 // guarantees a stopped/reset timer cannot deliver its old value.881 // guarantees a stopped/reset timer cannot deliver its old value.
858 retryTimer := time.NewTimer(time.Hour)882 retryTimer := time.NewTimer(time.Hour)
859 vrrTimer := time.NewTimer(time.Hour)883 vrrTimer := time.NewTimer(time.Hour)
884+ cursorTimer := time.NewTimer(time.Hour)
860 o.frame.startTimer()885 o.frame.startTimer()
861 retryTimer.Stop()886 retryTimer.Stop()
862 vrrTimer.Stop()887 vrrTimer.Stop()
888+ cursorTimer.Stop()
863 defer retryTimer.Stop()889 defer retryTimer.Stop()
864 defer vrrTimer.Stop()890 defer vrrTimer.Stop()
891+ defer cursorTimer.Stop()
865 defer o.frame.stopTimer()892 defer o.frame.stopTimer()
866 // seen is the latest content Seq per window. It is reported only while893 // seen is the latest content Seq per window. It is reported only while
867 // no frame is in flight: then the GPU has finished every frame that894 // no frame is in flight: then the GPU has finished every frame that
@@ -898,6 +925,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
898 } else {925 } else {
899 vrrTimer.Stop()926 vrrTimer.Stop()
900 }927 }
928+ // A held cursor move commits alone if no frame comes in time.
929+ var cursorDue <-chan time.Time
930+ if enabled && o.cursorHeld && !o.frame.pendingCommit() {
931+ cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame)))
932+ cursorDue = cursorTimer.C
933+ } else {
934+ cursorTimer.Stop()
935+ }
901 // A commit whose event never comes must not stop the output.936 // A commit whose event never comes must not stop the output.
902 stuck := o.frame.wait(enabled)937 stuck := o.frame.wait(enabled)
903 stateDirty := false938 stateDirty := false
@@ -920,6 +955,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
920 stateDirty = true955 stateDirty = true
921 case <-vrrOff:956 case <-vrrOff:
922 stateDirty = true957 stateDirty = true
958+ case <-cursorDue:
959+ stateDirty = true
923 case <-cursorWake:960 case <-cursorWake:
924 stateDirty = true961 stateDirty = true
925 case <-ctx.Done():962 case <-ctx.Done():
@@ -1149,7 +1186,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1149 return false1186 return false
1150 }1187 }
1151 if age := time.Since(start); age > 20*time.Millisecond {1188 if age := time.Since(start); age > 20*time.Millisecond {
1152- o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip")1189+ o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip")
1153 }1190 }
1154 if o.cursor != nil {1191 if o.cursor != nil {
1155 o.cursor.landed()1192 o.cursor.landed()
M internal/adapters/drm/output_test.go +67 -0
@@ -192,6 +192,73 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
192192 }
193193 }
194194
195+// Under VRR with a game shown, a cursor move rides on the next frame: a
196+// cursor-only commit would hold the panel for its slowest refresh. A game
197+// that stops drawing still gets the cursor after cursorMinInterval.
198+func TestVRRGameCursorWaitsForFrame(t *testing.T) {
199+ o, _, commits := testOutput(t)
200+ o.cursor.image = true
201+ o.vrrOn, o.vrrGame = true, true
202+ o.lastFrame = time.Now()
203+ o.cursor.Move(5, 5)
204+ if err := o.commitState(o.stateVRR()); err != nil {
205+ t.Fatal(err)
206+ }
207+ if len(*commits) != 0 || !o.cursorHeld || o.frame.pendingCommit() {
208+ t.Fatalf("cursor committed alone: %d commits held=%v", len(*commits), o.cursorHeld)
209+ }
210+ // The next frame carries the cursor.
211+ if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
212+ t.Fatal(err)
213+ }
214+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 5 || o.cursorHeld {
215+ t.Fatalf("frame cursor x %d held=%v", v, o.cursorHeld)
216+ }
217+ o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{})
218+ // The game stalls: the cursor commits alone after the interval.
219+ o.lastFrame = time.Now().Add(-cursorMinInterval)
220+ o.cursor.Move(8, 8)
221+ if err := o.commitState(o.stateVRR()); err != nil {
222+ t.Fatal(err)
223+ }
224+ if len(*commits) != 2 || o.cursorHeld {
225+ t.Fatalf("stalled game: %d commits held=%v", len(*commits), o.cursorHeld)
226+ }
227+ if v, _ := (*commits)[1].req.value(tCursor, 14); v != 8 {
228+ t.Fatalf("cursor x %d", v)
229+ }
230+}
231+
232+// A held move reversed before the next frame holds nothing: the cursor
233+// timer is not armed again.
234+func TestHeldCursorMovedBackClearsHold(t *testing.T) {
235+ o, _, commits := testOutput(t)
236+ o.cursor.image = true
237+ o.vrrOn, o.vrrGame, o.lastFrame = true, true, time.Now()
238+ o.cursor.Move(0, 0)
239+ o.cursor.applied = o.cursor.desired() // on screen at (0, 0)
240+ o.cursor.Move(5, 5)
241+ if err := o.commitState(o.stateVRR()); err != nil || !o.cursorHeld {
242+ t.Fatalf("err %v held=%v", err, o.cursorHeld)
243+ }
244+ o.cursor.Move(0, 0)
245+ if err := o.commitState(o.stateVRR()); err != nil || o.cursorHeld || len(*commits) != 0 {
246+ t.Fatalf("err %v held=%v commits %d", err, o.cursorHeld, len(*commits))
247+ }
248+}
249+
250+// Without a game, the cursor keeps its own commits.
251+func TestCursorCommitsAloneWithoutGame(t *testing.T) {
252+ o, _, commits := testOutput(t)
253+ o.cursor.image = true
254+ o.vrrOn, o.lastFrame = true, time.Now()
255+ o.composedSince = time.Now()
256+ o.cursor.Move(5, 5)
257+ if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 1 || o.cursorHeld {
258+ t.Fatalf("err %v, %d commits held=%v", err, len(*commits), o.cursorHeld)
259+ }
260+}
261+
195262 func TestBusyCommitStaysPending(t *testing.T) {
196263 o, _, _ := testOutput(t, unix.EBUSY)
197264 err := o.commitFrame(70, nil, false, false, pendingFrame{})
@@ -192,6 +192,73 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
192 }192 }
193 }193 }
194 194
195+// Under VRR with a game shown, a cursor move rides on the next frame: a
196+// cursor-only commit would hold the panel for its slowest refresh. A game
197+// that stops drawing still gets the cursor after cursorMinInterval.
198+func TestVRRGameCursorWaitsForFrame(t *testing.T) {
199+ o, _, commits := testOutput(t)
200+ o.cursor.image = true
201+ o.vrrOn, o.vrrGame = true, true
202+ o.lastFrame = time.Now()
203+ o.cursor.Move(5, 5)
204+ if err := o.commitState(o.stateVRR()); err != nil {
205+ t.Fatal(err)
206+ }
207+ if len(*commits) != 0 || !o.cursorHeld || o.frame.pendingCommit() {
208+ t.Fatalf("cursor committed alone: %d commits held=%v", len(*commits), o.cursorHeld)
209+ }
210+ // The next frame carries the cursor.
211+ if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
212+ t.Fatal(err)
213+ }
214+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 5 || o.cursorHeld {
215+ t.Fatalf("frame cursor x %d held=%v", v, o.cursorHeld)
216+ }
217+ o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{})
218+ // The game stalls: the cursor commits alone after the interval.
219+ o.lastFrame = time.Now().Add(-cursorMinInterval)
220+ o.cursor.Move(8, 8)
221+ if err := o.commitState(o.stateVRR()); err != nil {
222+ t.Fatal(err)
223+ }
224+ if len(*commits) != 2 || o.cursorHeld {
225+ t.Fatalf("stalled game: %d commits held=%v", len(*commits), o.cursorHeld)
226+ }
227+ if v, _ := (*commits)[1].req.value(tCursor, 14); v != 8 {
228+ t.Fatalf("cursor x %d", v)
229+ }
230+}
231+
232+// A held move reversed before the next frame holds nothing: the cursor
233+// timer is not armed again.
234+func TestHeldCursorMovedBackClearsHold(t *testing.T) {
235+ o, _, commits := testOutput(t)
236+ o.cursor.image = true
237+ o.vrrOn, o.vrrGame, o.lastFrame = true, true, time.Now()
238+ o.cursor.Move(0, 0)
239+ o.cursor.applied = o.cursor.desired() // on screen at (0, 0)
240+ o.cursor.Move(5, 5)
241+ if err := o.commitState(o.stateVRR()); err != nil || !o.cursorHeld {
242+ t.Fatalf("err %v held=%v", err, o.cursorHeld)
243+ }
244+ o.cursor.Move(0, 0)
245+ if err := o.commitState(o.stateVRR()); err != nil || o.cursorHeld || len(*commits) != 0 {
246+ t.Fatalf("err %v held=%v commits %d", err, o.cursorHeld, len(*commits))
247+ }
248+}
249+
250+// Without a game, the cursor keeps its own commits.
251+func TestCursorCommitsAloneWithoutGame(t *testing.T) {
252+ o, _, commits := testOutput(t)
253+ o.cursor.image = true
254+ o.vrrOn, o.lastFrame = true, time.Now()
255+ o.composedSince = time.Now()
256+ o.cursor.Move(5, 5)
257+ if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 1 || o.cursorHeld {
258+ t.Fatalf("err %v, %d commits held=%v", err, len(*commits), o.cursorHeld)
259+ }
260+}
261+
195 func TestBusyCommitStaysPending(t *testing.T) {262 func TestBusyCommitStaysPending(t *testing.T) {
196 o, _, _ := testOutput(t, unix.EBUSY)263 o, _, _ := testOutput(t, unix.EBUSY)
197 err := o.commitFrame(70, nil, false, false, pendingFrame{})264 err := o.commitFrame(70, nil, false, false, pendingFrame{})

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