detached 185b931

perf(vulkan): redraw only the changed rects when a scene's windows move

A scene with a new Seq redrew the whole output. The target now holds the
scene it was drawn from; sceneDelta (zero allocations) gives the union of
the old and new rects of each changed window plus the separators present
in only one scene, and everything else differing forces a full redraw.
The headless frame log gains the redrawn pixel count.
commit
185b93144dd3c49a97d01a1d24864dcc5a3eaa57
author
brice · · 2026-10-04 20:21 UTC
committer
brice · · 2026-10-04 20:57 UTC
parent
27a2aaf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +313 -15
M Makefile +1 -1
@@ -60,7 +60,7 @@ arch:
6060 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
6161 # Fast, mandatory guards for tiled content publications and SHM copies.
6262 perf-check:
63- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
63+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
6464 check: vet test arch fakes-check log-check perf-check
6565
6666 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -60,7 +60,7 @@ arch:
60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
61 # Fast, mandatory guards for tiled content publications and SHM copies.61 # Fast, mandatory guards for tiled content publications and SHM copies.
62 perf-check:62 perf-check:
63- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=163+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
64 check: vet test arch fakes-check log-check perf-check64 check: vet test arch fakes-check log-check perf-check
65 65
66 # On-demand SDR/HDR colour check with examples/testpattern on a headless66 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/adapters/headless/output.go +3 -1
@@ -617,7 +617,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
617617 }
618618 }
619619 }
620- opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")
620+ if ev := opts.Log.Debug(); ev.Enabled() {
621+ ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame")
622+ }
621623 }
622624 }
623625
@@ -617,7 +617,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
617 }617 }
618 }618 }
619 }619 }
620- opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")620+ if ev := opts.Log.Debug(); ev.Enabled() {
621+ ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame")
622+ }
621 }623 }
622 }624 }
623 625
M internal/adapters/vulkan/damage.go +90 -9
@@ -3,6 +3,7 @@ package vulkan
33 import (
44 "image"
55 "math"
6+ "slices"
67
78 "github.com/bnema/neferwl/internal/ports"
89 )
@@ -10,8 +11,10 @@ import (
1011 // Damage: an output target keeps what it holds (scene Seq, each window's
1112 // content Seq). When the scene is the same, only the region changed since
1213 // then is redrawn: every quad is clipped to it on the CPU and the pass
13-// keeps the rest of the image. A changed scene, a target never drawn or
14-// a history too short redraws everything.
14+// keeps the rest of the image. A scene with a new Seq redraws only the
15+// windows and separators that differ from the scene the target holds
16+// (sceneDelta). A scene that differs in anything else, a target never
17+// drawn or a history too short redraws everything.
1518
1619 // damageRegion collects the region of a target to redraw this frame and
1720 // what the target will hold after it: the windows drawn, their content
@@ -34,11 +37,34 @@ type heldWindow struct {
3437
3538 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
3639 d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}}
37- // Seq 0 is a scene core did not number (setup, tests): redraw all.
38- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
40+ d.hold(tg, s)
41+ return d
42+}
43+
44+// hold decides what the target keeps. Seq 0 is a scene core did not
45+// number (setup, tests): redraw all. The same Seq keeps everything; a new
46+// one keeps all but what sceneDelta finds changed.
47+func (d *damageRegion) hold(tg *target, s ports.Scene) {
48+ if !tg.valid || s.Seq == 0 {
49+ return
50+ }
51+ if tg.sceneSeq == s.Seq {
3952 d.held = tg
53+ return
54+ }
55+ scale := s.Scale
56+ if scale <= 0 {
57+ scale = 1
58+ }
59+ phys := func(r ports.Rect) image.Rectangle {
60+ p := func(v int) int { return int(math.Round(float64(v) * scale)) }
61+ return image.Rect(p(r.X), p(r.Y), p(r.X+r.W), p(r.Y+r.H)).Intersect(d.bound)
62+ }
63+ // The limit only bounds the CPU cost of clipping every quad.
64+ limit := d.bound.Dx() * d.bound.Dy() * 9 / 10
65+ if area, ok := sceneDelta(tg.scene, s, phys, limit); ok {
66+ d.held, d.area = tg, area
4067 }
41- return d
4268 }
4369
4470 // frameDamage reuses the bookkeeping maps. drawn is swapped with the
@@ -53,9 +79,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle
5379 }
5480 clear(r.damageDrawn)
5581 r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]}
56- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
57- r.damage.held = tg
58- }
82+ r.damage.hold(tg, s)
5983 return &r.damage
6084 }
6185
@@ -167,5 +191,62 @@ func (d *damageRegion) clip(ds []draw) []draw {
167191 // hold records what the target holds after this frame: only the windows
168192 // it drew.
169193 func (tg *target) hold(s ports.Scene, d *damageRegion) {
170- tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn
194+ tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn
195+}
196+
197+// sceneDelta is the region (physical, through phys) where cur differs from
198+// old, two scenes of one output; ok is false when the whole output must be
199+// redrawn: anything but the windows and separators differs, the windows
200+// are not the same list, or the region covers more than limit pixels.
201+// Each window that differs (any field) adds its old and new rects, each
202+// separator present in only one scene adds its own. A new Scene field
203+// belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a
204+// new SceneWindow field is checked by TestSceneWindowFieldCount.
205+func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) {
206+ if old.Security != cur.Security || old.Output != cur.Output ||
207+ old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
208+ old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||
209+ old.Background != cur.Background || old.Border != cur.Border ||
210+ old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
211+ old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
212+ !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
213+ !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
214+ len(old.Windows) != len(cur.Windows) {
215+ return image.Rectangle{}, false
216+ }
217+ var area image.Rectangle
218+ for i := range old.Windows {
219+ a, b := old.Windows[i], cur.Windows[i]
220+ if a.ID != b.ID {
221+ return image.Rectangle{}, false
222+ }
223+ if a != b {
224+ area = area.Union(phys(a.Rect)).Union(phys(b.Rect))
225+ }
226+ }
227+ // Separators: a line in one list more often than in the other.
228+ for _, sep := range old.Separators {
229+ if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) {
230+ area = area.Union(phys(sep.Rect))
231+ }
232+ }
233+ for _, sep := range cur.Separators {
234+ if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) {
235+ area = area.Union(phys(sep.Rect))
236+ }
237+ }
238+ if area.Dx()*area.Dy() > limit {
239+ return image.Rectangle{}, false
240+ }
241+ return area, true
242+}
243+
244+func countSeparator(list []ports.Separator, sep ports.Separator) int {
245+ n := 0
246+ for _, o := range list {
247+ if o == sep {
248+ n++
249+ }
250+ }
251+ return n
171252 }
@@ -3,6 +3,7 @@ package vulkan
3 import (3 import (
4 "image"4 "image"
5 "math"5 "math"
6+ "slices"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
8 )9 )
@@ -10,8 +11,10 @@ import (
10 // Damage: an output target keeps what it holds (scene Seq, each window's11 // Damage: an output target keeps what it holds (scene Seq, each window's
11 // content Seq). When the scene is the same, only the region changed since12 // content Seq). When the scene is the same, only the region changed since
12 // then is redrawn: every quad is clipped to it on the CPU and the pass13 // then is redrawn: every quad is clipped to it on the CPU and the pass
13-// keeps the rest of the image. A changed scene, a target never drawn or14+// keeps the rest of the image. A scene with a new Seq redraws only the
14-// a history too short redraws everything.15+// windows and separators that differ from the scene the target holds
16+// (sceneDelta). A scene that differs in anything else, a target never
17+// drawn or a history too short redraws everything.
15 18
16 // damageRegion collects the region of a target to redraw this frame and19 // damageRegion collects the region of a target to redraw this frame and
17 // what the target will hold after it: the windows drawn, their content20 // what the target will hold after it: the windows drawn, their content
@@ -34,11 +37,34 @@ type heldWindow struct {
34 37
35 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {38 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
36 d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}}39 d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}}
37- // Seq 0 is a scene core did not number (setup, tests): redraw all.40+ d.hold(tg, s)
38- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {41+ return d
42+}
43+
44+// hold decides what the target keeps. Seq 0 is a scene core did not
45+// number (setup, tests): redraw all. The same Seq keeps everything; a new
46+// one keeps all but what sceneDelta finds changed.
47+func (d *damageRegion) hold(tg *target, s ports.Scene) {
48+ if !tg.valid || s.Seq == 0 {
49+ return
50+ }
51+ if tg.sceneSeq == s.Seq {
39 d.held = tg52 d.held = tg
53+ return
54+ }
55+ scale := s.Scale
56+ if scale <= 0 {
57+ scale = 1
58+ }
59+ phys := func(r ports.Rect) image.Rectangle {
60+ p := func(v int) int { return int(math.Round(float64(v) * scale)) }
61+ return image.Rect(p(r.X), p(r.Y), p(r.X+r.W), p(r.Y+r.H)).Intersect(d.bound)
62+ }
63+ // The limit only bounds the CPU cost of clipping every quad.
64+ limit := d.bound.Dx() * d.bound.Dy() * 9 / 10
65+ if area, ok := sceneDelta(tg.scene, s, phys, limit); ok {
66+ d.held, d.area = tg, area
40 }67 }
41- return d
42 }68 }
43 69
44 // frameDamage reuses the bookkeeping maps. drawn is swapped with the70 // frameDamage reuses the bookkeeping maps. drawn is swapped with the
@@ -53,9 +79,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle
53 }79 }
54 clear(r.damageDrawn)80 clear(r.damageDrawn)
55 r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]}81 r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]}
56- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {82+ r.damage.hold(tg, s)
57- r.damage.held = tg
58- }
59 return &r.damage83 return &r.damage
60 }84 }
61 85
@@ -167,5 +191,62 @@ func (d *damageRegion) clip(ds []draw) []draw {
167 // hold records what the target holds after this frame: only the windows191 // hold records what the target holds after this frame: only the windows
168 // it drew.192 // it drew.
169 func (tg *target) hold(s ports.Scene, d *damageRegion) {193 func (tg *target) hold(s ports.Scene, d *damageRegion) {
170- tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn194+ tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn
195+}
196+
197+// sceneDelta is the region (physical, through phys) where cur differs from
198+// old, two scenes of one output; ok is false when the whole output must be
199+// redrawn: anything but the windows and separators differs, the windows
200+// are not the same list, or the region covers more than limit pixels.
201+// Each window that differs (any field) adds its old and new rects, each
202+// separator present in only one scene adds its own. A new Scene field
203+// belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a
204+// new SceneWindow field is checked by TestSceneWindowFieldCount.
205+func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) {
206+ if old.Security != cur.Security || old.Output != cur.Output ||
207+ old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
208+ old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||
209+ old.Background != cur.Background || old.Border != cur.Border ||
210+ old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
211+ old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
212+ !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
213+ !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
214+ len(old.Windows) != len(cur.Windows) {
215+ return image.Rectangle{}, false
216+ }
217+ var area image.Rectangle
218+ for i := range old.Windows {
219+ a, b := old.Windows[i], cur.Windows[i]
220+ if a.ID != b.ID {
221+ return image.Rectangle{}, false
222+ }
223+ if a != b {
224+ area = area.Union(phys(a.Rect)).Union(phys(b.Rect))
225+ }
226+ }
227+ // Separators: a line in one list more often than in the other.
228+ for _, sep := range old.Separators {
229+ if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) {
230+ area = area.Union(phys(sep.Rect))
231+ }
232+ }
233+ for _, sep := range cur.Separators {
234+ if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) {
235+ area = area.Union(phys(sep.Rect))
236+ }
237+ }
238+ if area.Dx()*area.Dy() > limit {
239+ return image.Rectangle{}, false
240+ }
241+ return area, true
242+}
243+
244+func countSeparator(list []ports.Separator, sep ports.Separator) int {
245+ n := 0
246+ for _, o := range list {
247+ if o == sep {
248+ n++
249+ }
250+ }
251+ return n
171 }252 }
A internal/adapters/vulkan/damage_delta_test.go +211 -0
@@ -0,0 +1,211 @@
1+package vulkan
2+
3+import (
4+ "bytes"
5+ "image"
6+ "image/color"
7+ "reflect"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+// TestSceneWindowFieldCount fails when SceneWindow gains or loses a field:
17+// sceneDelta compares windows by struct equality, so a new field is covered
18+// as long as it only changes where or how that window draws (inside Rect).
19+func TestSceneWindowFieldCount(t *testing.T) {
20+ const fields = 13
21+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
22+ "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
23+}
24+
25+func deltaScene(seq uint64, ws ...ports.SceneWindow) ports.Scene {
26+ return ports.Scene{Seq: seq, OutputWidth: 1000, OutputHeight: 500, Scale: 1, Background: "#000000", Windows: ws}
27+}
28+
29+func win(id ports.WindowID, x, y, w, h int) ports.SceneWindow {
30+ return ports.SceneWindow{ID: id, Rect: ports.Rect{X: x, Y: y, W: w, H: h}}
31+}
32+
33+// held is a target that holds s after a full frame of its windows.
34+func held(s ports.Scene) *target {
35+ tg := &target{}
36+ tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
37+ return tg
38+}
39+
40+func TestDamageOneWindowResizeIsUnionOfRects(t *testing.T) {
41+ bounds := image.Rect(0, 0, 1000, 500)
42+ old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
43+ cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 400))
44+ d := newDamage(held(old), cur, bounds)
45+ require.False(t, d.all())
46+ assert.Equal(t, image.Rect(600, 0, 1000, 500), d.area)
47+
48+ // Window moves: both places.
49+ cur = deltaScene(2, win(1, 0, 0, 300, 500), win(2, 310, 0, 300, 500), win(3, 600, 0, 400, 500))
50+ d = newDamage(held(old), cur, bounds)
51+ require.False(t, d.all())
52+ assert.Equal(t, image.Rect(300, 0, 610, 500), d.area)
53+}
54+
55+func TestDamageDeltaAtScale(t *testing.T) {
56+ old := deltaScene(1, win(1, 0, 0, 10, 10), win(2, 100, 0, 10, 10))
57+ cur := deltaScene(2, win(1, 0, 0, 10, 10), win(2, 100, 0, 20, 10))
58+ old.Scale, cur.Scale = 1.5, 1.5
59+ d := newDamage(held(old), cur, image.Rect(0, 0, 1500, 750))
60+ require.False(t, d.all())
61+ assert.Equal(t, image.Rect(150, 0, 180, 15), d.area)
62+}
63+
64+func TestDamageDeltaWindowFieldChange(t *testing.T) {
65+ old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
66+ cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
67+ cur.Windows[0].FocusEffect = 0.3
68+ d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
69+ require.False(t, d.all())
70+ assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
71+}
72+
73+func TestDamageDeltaFullRedraws(t *testing.T) {
74+ base := func() ports.Scene {
75+ return deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
76+ }
77+ bounds := image.Rect(0, 0, 1000, 500)
78+ for name, mod := range map[string]func(*ports.Scene){
79+ "camera shift over half": func(s *ports.Scene) {
80+ s.Windows = []ports.SceneWindow{win(1, 0, 0, 600, 500), win(2, 600, 0, 400, 500)}
81+ s.Windows[0].Rect.X = 50
82+ },
83+ "layer": func(s *ports.Scene) { s.Layers = []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} },
84+ "output": func(s *ports.Scene) { s.Output = "DP-2" },
85+ "size": func(s *ports.Scene) { s.OutputWidth = 999 },
86+ "scale": func(s *ports.Scene) { s.Scale = 2 },
87+ "transform": func(s *ports.Scene) { s.Transform = 1 },
88+ "off": func(s *ports.Scene) { s.Off = true },
89+ "background": func(s *ports.Scene) { s.Background = "#111111" },
90+ "border": func(s *ports.Scene) { s.Border.Width = 2 },
91+ "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },
92+ "dim": func(s *ports.Scene) { s.Dim = 0.5 },
93+ "dim behind": func(s *ports.Scene) { s.DimBehind = true },
94+ "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
95+ "indicators": func(s *ports.Scene) { s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} },
96+ "capture": func(s *ports.Scene) { s.Capture = &ports.SceneCapture{} },
97+ "capture scene": func(s *ports.Scene) { s.CaptureScene = &ports.Scene{} },
98+ "security": func(s *ports.Scene) { s.Security.Protected = true },
99+ "window count": func(s *ports.Scene) { s.Windows = s.Windows[:1] },
100+ "window order": func(s *ports.Scene) { s.Windows[0], s.Windows[1] = s.Windows[1], s.Windows[0] },
101+ "window id": func(s *ports.Scene) { s.Windows[1].ID = 7 },
102+ } {
103+ t.Run(name, func(t *testing.T) {
104+ cur := base()
105+ mod(&cur)
106+ cur.Seq = 2
107+ assert.True(t, newDamage(held(base()), cur, bounds).all())
108+ })
109+ }
110+ t.Run("seq 0 and cold target", func(t *testing.T) {
111+ cur := base()
112+ cur.Seq = 0
113+ assert.True(t, newDamage(held(base()), cur, bounds).all())
114+ cur.Seq = 2
115+ assert.True(t, newDamage(&target{}, cur, bounds).all())
116+ })
117+}
118+
119+func TestDamageDeltaSeparators(t *testing.T) {
120+ sep := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 500}} }
121+ a, b := sep(100), sep(200)
122+ old := deltaScene(1, win(1, 0, 0, 500, 500))
123+ old.Separators = []ports.Separator{a, b}
124+ cur := deltaScene(2, win(1, 0, 0, 500, 500))
125+ cur.Separators = []ports.Separator{a, sep(250)}
126+ d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
127+ require.False(t, d.all())
128+ assert.Equal(t, image.Rect(200, 0, 252, 500), d.area)
129+
130+ // Same lines, another order: nothing differs by value.
131+ cur.Separators = []ports.Separator{b, a}
132+ d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
133+ require.False(t, d.all())
134+ assert.True(t, d.area.Empty())
135+
136+ // A line that turns active is a different value.
137+ cur.Separators = []ports.Separator{a, {Rect: b.Rect, Active: true}}
138+ d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
139+ require.False(t, d.all())
140+ assert.Equal(t, image.Rect(200, 0, 202, 500), d.area)
141+}
142+
143+func TestSceneDeltaAllocations(t *testing.T) {
144+ old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
145+ cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 500))
146+ for i := range 6 {
147+ s := ports.Separator{Rect: ports.Rect{X: i * 100, W: 2, H: 500}}
148+ old.Separators = append(old.Separators, s)
149+ cur.Separators = append(cur.Separators, s)
150+ }
151+ cur.Separators[5].Active = true
152+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
153+ var area image.Rectangle
154+ var ok bool
155+ allocs := testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, cur, phys, 450000) })
156+ require.True(t, ok)
157+ assert.False(t, area.Empty())
158+ assert.Zero(t, allocs, "sceneDelta must not allocate")
159+}
160+
161+// A partial redraw after a window resize is pixel-identical to a full
162+// redraw, on both targets of a double-buffered output.
163+func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) {
164+ newR := func() *Renderer {
165+ r, err := New(64, 48)
166+ if err != nil {
167+ t.Skipf("Vulkan unavailable: %v", err)
168+ }
169+ t.Cleanup(r.Close)
170+ return r
171+ }
172+ damaged, full := newR(), newR()
173+ bufs, err := damaged.ExportTargets(2, nil, false)
174+ if err != nil {
175+ t.Skipf("no exportable targets: %v", err)
176+ }
177+ for _, b := range bufs {
178+ for _, p := range b.Planes {
179+ p.File.Close()
180+ }
181+ }
182+ red := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
183+ red.ID, red.Surface, red.Seq, red.Version = 1, 1, 1, 1
184+ green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255})
185+ green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1
186+ contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green}
187+ mk := func(seq uint64, w2 int) ports.Scene {
188+ s := ports.Scene{Seq: seq, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#101010",
189+ Windows: []ports.SceneWindow{win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48)}}
190+ s.Separators = []ports.Separator{{Rect: ports.Rect{X: 20, W: 1, H: 48}}}
191+ return s
192+ }
193+ var partial int
194+ for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20} {
195+ s := mk(uint64(10+i), w2)
196+ damaged.UseTarget(i % 2)
197+ before := damaged.redrawn
198+ require.NoError(t, render(damaged, s, contents))
199+ if damaged.redrawn-before < 64*48 {
200+ partial++
201+ }
202+ plain := s
203+ plain.Seq = 0
204+ require.NoError(t, render(full, plain, contents))
205+ a, b := readPixels(t, damaged), readPixels(t, full)
206+ if !bytes.Equal(a.Pix, b.Pix) {
207+ t.Fatalf("frame %d (w2=%d) differs from a full redraw", i, w2)
208+ }
209+ }
210+ assert.Positive(t, partial, "no frame was redrawn partially")
211+}
@@ -0,0 +1,211 @@
1+package vulkan
2+
3+import (
4+ "bytes"
5+ "image"
6+ "image/color"
7+ "reflect"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+// TestSceneWindowFieldCount fails when SceneWindow gains or loses a field:
17+// sceneDelta compares windows by struct equality, so a new field is covered
18+// as long as it only changes where or how that window draws (inside Rect).
19+func TestSceneWindowFieldCount(t *testing.T) {
20+ const fields = 13
21+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
22+ "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
23+}
24+
25+func deltaScene(seq uint64, ws ...ports.SceneWindow) ports.Scene {
26+ return ports.Scene{Seq: seq, OutputWidth: 1000, OutputHeight: 500, Scale: 1, Background: "#000000", Windows: ws}
27+}
28+
29+func win(id ports.WindowID, x, y, w, h int) ports.SceneWindow {
30+ return ports.SceneWindow{ID: id, Rect: ports.Rect{X: x, Y: y, W: w, H: h}}
31+}
32+
33+// held is a target that holds s after a full frame of its windows.
34+func held(s ports.Scene) *target {
35+ tg := &target{}
36+ tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
37+ return tg
38+}
39+
40+func TestDamageOneWindowResizeIsUnionOfRects(t *testing.T) {
41+ bounds := image.Rect(0, 0, 1000, 500)
42+ old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
43+ cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 400))
44+ d := newDamage(held(old), cur, bounds)
45+ require.False(t, d.all())
46+ assert.Equal(t, image.Rect(600, 0, 1000, 500), d.area)
47+
48+ // Window moves: both places.
49+ cur = deltaScene(2, win(1, 0, 0, 300, 500), win(2, 310, 0, 300, 500), win(3, 600, 0, 400, 500))
50+ d = newDamage(held(old), cur, bounds)
51+ require.False(t, d.all())
52+ assert.Equal(t, image.Rect(300, 0, 610, 500), d.area)
53+}
54+
55+func TestDamageDeltaAtScale(t *testing.T) {
56+ old := deltaScene(1, win(1, 0, 0, 10, 10), win(2, 100, 0, 10, 10))
57+ cur := deltaScene(2, win(1, 0, 0, 10, 10), win(2, 100, 0, 20, 10))
58+ old.Scale, cur.Scale = 1.5, 1.5
59+ d := newDamage(held(old), cur, image.Rect(0, 0, 1500, 750))
60+ require.False(t, d.all())
61+ assert.Equal(t, image.Rect(150, 0, 180, 15), d.area)
62+}
63+
64+func TestDamageDeltaWindowFieldChange(t *testing.T) {
65+ old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
66+ cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
67+ cur.Windows[0].FocusEffect = 0.3
68+ d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
69+ require.False(t, d.all())
70+ assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
71+}
72+
73+func TestDamageDeltaFullRedraws(t *testing.T) {
74+ base := func() ports.Scene {
75+ return deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
76+ }
77+ bounds := image.Rect(0, 0, 1000, 500)
78+ for name, mod := range map[string]func(*ports.Scene){
79+ "camera shift over half": func(s *ports.Scene) {
80+ s.Windows = []ports.SceneWindow{win(1, 0, 0, 600, 500), win(2, 600, 0, 400, 500)}
81+ s.Windows[0].Rect.X = 50
82+ },
83+ "layer": func(s *ports.Scene) { s.Layers = []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} },
84+ "output": func(s *ports.Scene) { s.Output = "DP-2" },
85+ "size": func(s *ports.Scene) { s.OutputWidth = 999 },
86+ "scale": func(s *ports.Scene) { s.Scale = 2 },
87+ "transform": func(s *ports.Scene) { s.Transform = 1 },
88+ "off": func(s *ports.Scene) { s.Off = true },
89+ "background": func(s *ports.Scene) { s.Background = "#111111" },
90+ "border": func(s *ports.Scene) { s.Border.Width = 2 },
91+ "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },
92+ "dim": func(s *ports.Scene) { s.Dim = 0.5 },
93+ "dim behind": func(s *ports.Scene) { s.DimBehind = true },
94+ "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
95+ "indicators": func(s *ports.Scene) { s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} },
96+ "capture": func(s *ports.Scene) { s.Capture = &ports.SceneCapture{} },
97+ "capture scene": func(s *ports.Scene) { s.CaptureScene = &ports.Scene{} },
98+ "security": func(s *ports.Scene) { s.Security.Protected = true },
99+ "window count": func(s *ports.Scene) { s.Windows = s.Windows[:1] },
100+ "window order": func(s *ports.Scene) { s.Windows[0], s.Windows[1] = s.Windows[1], s.Windows[0] },
101+ "window id": func(s *ports.Scene) { s.Windows[1].ID = 7 },
102+ } {
103+ t.Run(name, func(t *testing.T) {
104+ cur := base()
105+ mod(&cur)
106+ cur.Seq = 2
107+ assert.True(t, newDamage(held(base()), cur, bounds).all())
108+ })
109+ }
110+ t.Run("seq 0 and cold target", func(t *testing.T) {
111+ cur := base()
112+ cur.Seq = 0
113+ assert.True(t, newDamage(held(base()), cur, bounds).all())
114+ cur.Seq = 2
115+ assert.True(t, newDamage(&target{}, cur, bounds).all())
116+ })
117+}
118+
119+func TestDamageDeltaSeparators(t *testing.T) {
120+ sep := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 500}} }
121+ a, b := sep(100), sep(200)
122+ old := deltaScene(1, win(1, 0, 0, 500, 500))
123+ old.Separators = []ports.Separator{a, b}
124+ cur := deltaScene(2, win(1, 0, 0, 500, 500))
125+ cur.Separators = []ports.Separator{a, sep(250)}
126+ d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
127+ require.False(t, d.all())
128+ assert.Equal(t, image.Rect(200, 0, 252, 500), d.area)
129+
130+ // Same lines, another order: nothing differs by value.
131+ cur.Separators = []ports.Separator{b, a}
132+ d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
133+ require.False(t, d.all())
134+ assert.True(t, d.area.Empty())
135+
136+ // A line that turns active is a different value.
137+ cur.Separators = []ports.Separator{a, {Rect: b.Rect, Active: true}}
138+ d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
139+ require.False(t, d.all())
140+ assert.Equal(t, image.Rect(200, 0, 202, 500), d.area)
141+}
142+
143+func TestSceneDeltaAllocations(t *testing.T) {
144+ old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
145+ cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 500))
146+ for i := range 6 {
147+ s := ports.Separator{Rect: ports.Rect{X: i * 100, W: 2, H: 500}}
148+ old.Separators = append(old.Separators, s)
149+ cur.Separators = append(cur.Separators, s)
150+ }
151+ cur.Separators[5].Active = true
152+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
153+ var area image.Rectangle
154+ var ok bool
155+ allocs := testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, cur, phys, 450000) })
156+ require.True(t, ok)
157+ assert.False(t, area.Empty())
158+ assert.Zero(t, allocs, "sceneDelta must not allocate")
159+}
160+
161+// A partial redraw after a window resize is pixel-identical to a full
162+// redraw, on both targets of a double-buffered output.
163+func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) {
164+ newR := func() *Renderer {
165+ r, err := New(64, 48)
166+ if err != nil {
167+ t.Skipf("Vulkan unavailable: %v", err)
168+ }
169+ t.Cleanup(r.Close)
170+ return r
171+ }
172+ damaged, full := newR(), newR()
173+ bufs, err := damaged.ExportTargets(2, nil, false)
174+ if err != nil {
175+ t.Skipf("no exportable targets: %v", err)
176+ }
177+ for _, b := range bufs {
178+ for _, p := range b.Planes {
179+ p.File.Close()
180+ }
181+ }
182+ red := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
183+ red.ID, red.Surface, red.Seq, red.Version = 1, 1, 1, 1
184+ green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255})
185+ green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1
186+ contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green}
187+ mk := func(seq uint64, w2 int) ports.Scene {
188+ s := ports.Scene{Seq: seq, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#101010",
189+ Windows: []ports.SceneWindow{win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48)}}
190+ s.Separators = []ports.Separator{{Rect: ports.Rect{X: 20, W: 1, H: 48}}}
191+ return s
192+ }
193+ var partial int
194+ for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20} {
195+ s := mk(uint64(10+i), w2)
196+ damaged.UseTarget(i % 2)
197+ before := damaged.redrawn
198+ require.NoError(t, render(damaged, s, contents))
199+ if damaged.redrawn-before < 64*48 {
200+ partial++
201+ }
202+ plain := s
203+ plain.Seq = 0
204+ require.NoError(t, render(full, plain, contents))
205+ a, b := readPixels(t, damaged), readPixels(t, full)
206+ if !bytes.Equal(a.Pix, b.Pix) {
207+ t.Fatalf("frame %d (w2=%d) differs from a full redraw", i, w2)
208+ }
209+ }
210+ assert.Positive(t, partial, "no frame was redrawn partially")
211+}
M internal/adapters/vulkan/targets.go +4 -2
@@ -31,10 +31,12 @@ type target struct {
3131 // exported images are shared with the display: ownership goes to the
3232 // foreign queue family after each frame, and KMS reads them.
3333 exported bool
34- // What the image holds (damage.go): the scene Seq and each window's
35- // content Seq; valid once a frame was drawn whole.
34+ // What the image holds (damage.go): the scene (an immutable snapshot,
35+ // held by reference), its Seq and each window's content Seq; valid
36+ // once a frame was drawn whole.
3637 valid bool
3738 sceneSeq uint64
39+ scene ports.Scene
3840 windows map[ports.WindowID]heldWindow
3941 }
4042
@@ -31,10 +31,12 @@ type target struct {
31 // exported images are shared with the display: ownership goes to the31 // exported images are shared with the display: ownership goes to the
32 // foreign queue family after each frame, and KMS reads them.32 // foreign queue family after each frame, and KMS reads them.
33 exported bool33 exported bool
34- // What the image holds (damage.go): the scene Seq and each window's34+ // What the image holds (damage.go): the scene (an immutable snapshot,
35- // content Seq; valid once a frame was drawn whole.35+ // held by reference), its Seq and each window's content Seq; valid
36+ // once a frame was drawn whole.
36 valid bool37 valid bool
37 sceneSeq uint6438 sceneSeq uint64
39+ scene ports.Scene
38 windows map[ports.WindowID]heldWindow40 windows map[ports.WindowID]heldWindow
39 }41 }
40 42
M internal/adapters/vulkan/workspace_clip_test.go +4 -2
@@ -56,15 +56,17 @@ func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
5656 t.Fatalf("popup escaped clip: %v", got)
5757 }
5858 // Popups above layers belong to output-wide controls, not the workspace.
59+ // Scenes are immutable snapshots (the target holds the previous one):
60+ // a changed window goes in a fresh slice, as core publishes it.
5961 scene.Seq++
60- scene.Windows[0].OverLayers = true
62+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true, OverLayers: true}}
6163 if err := render(r, scene, contents); err != nil {
6264 t.Fatal(err)
6365 }
6466 if got := readPixels(t, r).RGBAAt(10, 10); got != red {
6567 t.Fatalf("output-wide popup clipped: %v", got)
6668 }
67- scene.Windows[0].OverLayers = false
69+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}}
6870 // Switching back to inherited geometry repaints the margins.
6971 scene.Seq++
7072 scene.WorkspaceClip = ports.Rect{}
@@ -56,15 +56,17 @@ func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
56 t.Fatalf("popup escaped clip: %v", got)56 t.Fatalf("popup escaped clip: %v", got)
57 }57 }
58 // Popups above layers belong to output-wide controls, not the workspace.58 // Popups above layers belong to output-wide controls, not the workspace.
59+ // Scenes are immutable snapshots (the target holds the previous one):
60+ // a changed window goes in a fresh slice, as core publishes it.
59 scene.Seq++61 scene.Seq++
60- scene.Windows[0].OverLayers = true62+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true, OverLayers: true}}
61 if err := render(r, scene, contents); err != nil {63 if err := render(r, scene, contents); err != nil {
62 t.Fatal(err)64 t.Fatal(err)
63 }65 }
64 if got := readPixels(t, r).RGBAAt(10, 10); got != red {66 if got := readPixels(t, r).RGBAAt(10, 10); got != red {
65 t.Fatalf("output-wide popup clipped: %v", got)67 t.Fatalf("output-wide popup clipped: %v", got)
66 }68 }
67- scene.Windows[0].OverLayers = false69+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}}
68 // Switching back to inherited geometry repaints the margins.70 // Switching back to inherited geometry repaints the margins.
69 scene.Seq++71 scene.Seq++
70 scene.WorkspaceClip = ports.Rect{}72 scene.WorkspaceClip = ports.Rect{}

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