detached 2bcca8a

fix(presentation): present only windows a flip draws and keep software flips out of frame pacing

DRM flips report the windows the scene draws (or the scanned-out one), so feedback of hidden windows is discarded. Frame done uses a flip time only on a flip round. Headless flips answer feedback without flags and do not pace callbacks; a report it could not send counts as merged. Only a commit with a new buffer replaces a surface's waiting feedback, and feedback follows the surface's current output.
commit
2bcca8a89d12ea22b1709d9f34163f4e82494532
author
brice · · 2026-09-26 12:18 UTC
parent
76522c2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +68 -22
M internal/adapters/drm/output.go +15 -2
@@ -818,11 +818,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
818818 continue
819819 }
820820 start := time.Now()
821- f := pendingFrame{shows: maps.Clone(seen)}
821+ // shows is what the frame puts on screen: presentation feedback
822+ // of windows it does not draw is discarded, not presented.
823+ f := pendingFrame{shows: shownBy(scene, seen)}
822824 fb, c := o.scanoutFrame(scene, surfaces)
823825 direct := false
824826 if fb != 0 {
825- direct, err = o.commitScanout(fb, c, f)
827+ direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}})
826828 }
827829 if !direct {
828830 ov, composed := o.overlayFrame(scene, surfaces)
@@ -1087,3 +1089,14 @@ func (o *Output) freeImages() {
10871089 o.fbs[i] = 0
10881090 }
10891091 }
1092+
1093+// shownBy is the content Seq of each window the scene draws.
1094+func shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
1095+ out := map[ports.WindowID]uint64{}
1096+ for id, seq := range seen {
1097+ if s.Shows(id) {
1098+ out[id] = seq
1099+ }
1100+ }
1101+ return out
1102+}
@@ -818,11 +818,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
818 continue818 continue
819 }819 }
820 start := time.Now()820 start := time.Now()
821- f := pendingFrame{shows: maps.Clone(seen)}821+ // shows is what the frame puts on screen: presentation feedback
822+ // of windows it does not draw is discarded, not presented.
823+ f := pendingFrame{shows: shownBy(scene, seen)}
822 fb, c := o.scanoutFrame(scene, surfaces)824 fb, c := o.scanoutFrame(scene, surfaces)
823 direct := false825 direct := false
824 if fb != 0 {826 if fb != 0 {
825- direct, err = o.commitScanout(fb, c, f)827+ direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}})
826 }828 }
827 if !direct {829 if !direct {
828 ov, composed := o.overlayFrame(scene, surfaces)830 ov, composed := o.overlayFrame(scene, surfaces)
@@ -1087,3 +1089,14 @@ func (o *Output) freeImages() {
1087 o.fbs[i] = 01089 o.fbs[i] = 0
1088 }1090 }
1089 }1091 }
1092+
1093+// shownBy is the content Seq of each window the scene draws.
1094+func shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
1095+ out := map[ports.WindowID]uint64{}
1096+ for id, seq := range seen {
1097+ if s.Shows(id) {
1098+ out[id] = seq
1099+ }
1100+ }
1101+ return out
1102+}
M internal/adapters/drm/scanout_test.go +10 -0
@@ -143,3 +143,13 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
143143 t.Fatal("tiled accepted without IN_FORMATS")
144144 }
145145 }
146+
147+// A flip shows only the windows its scene draws: presentation feedback of
148+// a window on another workspace or hidden is discarded, not presented.
149+func TestShownByScene(t *testing.T) {
150+ s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}}
151+ got := shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})
152+ if len(got) != 2 || got[1] != 3 || got[5] != 6 {
153+ t.Fatalf("shows %v", got)
154+ }
155+}
@@ -143,3 +143,13 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
143 t.Fatal("tiled accepted without IN_FORMATS")143 t.Fatal("tiled accepted without IN_FORMATS")
144 }144 }
145 }145 }
146+
147+// A flip shows only the windows its scene draws: presentation feedback of
148+// a window on another workspace or hidden is discarded, not presented.
149+func TestShownByScene(t *testing.T) {
150+ s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}}
151+ got := shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})
152+ if len(got) != 2 || got[1] != 3 || got[5] != 6 {
153+ t.Fatalf("shows %v", got)
154+ }
155+}
M internal/adapters/headless/output.go +7 -3
@@ -187,9 +187,13 @@ func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.Windo
187187 if opts.Presented == nil {
188188 return nil
189189 }
190+ var merged int
190191 if pending != nil && opts.send(pending) != nil {
191- // The reader is behind: this frame's report replaces the waiting one.
192- pending = nil
192+ // The reader is behind: this frame's report replaces the waiting
193+ // one, and its flip counts as merged (its feedback is discarded).
194+ if pending.Flip != nil {
195+ merged = 1 + pending.Flip.Merged
196+ }
193197 }
194198 var ts unix.Timespec
195199 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
@@ -199,7 +203,7 @@ func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.Windo
199203 shows[id] = c.Seq
200204 }
201205 }
202- return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}})
206+ return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows, Merged: merged}})
203207 }
204208
205209 func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
@@ -187,9 +187,13 @@ func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.Windo
187 if opts.Presented == nil {187 if opts.Presented == nil {
188 return nil188 return nil
189 }189 }
190+ var merged int
190 if pending != nil && opts.send(pending) != nil {191 if pending != nil && opts.send(pending) != nil {
191- // The reader is behind: this frame's report replaces the waiting one.192+ // The reader is behind: this frame's report replaces the waiting
192- pending = nil193+ // one, and its flip counts as merged (its feedback is discarded).
194+ if pending.Flip != nil {
195+ merged = 1 + pending.Flip.Merged
196+ }
193 }197 }
194 var ts unix.Timespec198 var ts unix.Timespec
195 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)199 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
@@ -199,7 +203,7 @@ func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.Windo
199 shows[id] = c.Seq203 shows[id] = c.Seq
200 }204 }
201 }205 }
202- return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}})206+ return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows, Merged: merged}})
203 }207 }
204 208
205 func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {209 func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
M internal/adapters/wayland/frames.go +11 -5
@@ -35,7 +35,7 @@ func (s *Server) pace(ctx context.Context) {
3535 case <-s.display.Stopped():
3636 return
3737 case p := <-presented:
38- flipped[p.Output] = flipped[p.Output] || p.Flip != nil
38+ flipped[p.Output] = flipped[p.Output] || paces(p)
3939 reports = append(reports, p)
4040 case <-s.frameReady:
4141 case <-timer.C:
@@ -46,7 +46,7 @@ func (s *Server) pace(ctx context.Context) {
4646 for {
4747 select {
4848 case p := <-presented:
49- flipped[p.Output] = flipped[p.Output] || p.Flip != nil
49+ flipped[p.Output] = flipped[p.Output] || paces(p)
5050 reports = append(reports, p)
5151 default:
5252 break drain
@@ -76,7 +76,7 @@ func (s *Server) pace(ctx context.Context) {
7676 }
7777 wait = dueWait
7878 for _, name := range due {
79- s.sendFrames(name)
79+ s.sendFrames(name, flipped[name])
8080 }
8181 if s.releaseHeld(time.Now(), reports) && idle {
8282 // Held buffers wait for a flip or heldTimeout.
@@ -186,7 +186,13 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
186186 return fire, max(wait, time.Millisecond), !waiting
187187 }
188188
189-func (s *Server) sendFrames(name string) {
189+// paces reports whether a report paces frame callbacks: a flip of a real
190+// display. Software flips (headless) answer presentation feedback, but
191+// callbacks keep the output's refresh period.
192+func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.HardwareClock }
193+
194+// sendFrames fires an output's callbacks; flipped: a flip of it fired them.
195+func (s *Server) sendFrames(name string, flipped bool) {
190196 callbacks := s.awaiting[name]
191197 delete(s.awaiting, name)
192198 if len(callbacks) == 0 {
@@ -195,7 +201,7 @@ func (s *Server) sendFrames(name string) {
195201 // done carries the flip's time when the output flips (the frame the
196202 // client drew for is on screen), else now.
197203 ms := uint32(time.Since(s.started).Milliseconds())
198- if f, ok := s.flips[name]; ok && f.When > 0 {
204+ if f, ok := s.flips[name]; ok && flipped && f.When > 0 {
199205 ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())
200206 }
201207 for _, cb := range callbacks {
@@ -35,7 +35,7 @@ func (s *Server) pace(ctx context.Context) {
35 case <-s.display.Stopped():35 case <-s.display.Stopped():
36 return36 return
37 case p := <-presented:37 case p := <-presented:
38- flipped[p.Output] = flipped[p.Output] || p.Flip != nil38+ flipped[p.Output] = flipped[p.Output] || paces(p)
39 reports = append(reports, p)39 reports = append(reports, p)
40 case <-s.frameReady:40 case <-s.frameReady:
41 case <-timer.C:41 case <-timer.C:
@@ -46,7 +46,7 @@ func (s *Server) pace(ctx context.Context) {
46 for {46 for {
47 select {47 select {
48 case p := <-presented:48 case p := <-presented:
49- flipped[p.Output] = flipped[p.Output] || p.Flip != nil49+ flipped[p.Output] = flipped[p.Output] || paces(p)
50 reports = append(reports, p)50 reports = append(reports, p)
51 default:51 default:
52 break drain52 break drain
@@ -76,7 +76,7 @@ func (s *Server) pace(ctx context.Context) {
76 }76 }
77 wait = dueWait77 wait = dueWait
78 for _, name := range due {78 for _, name := range due {
79- s.sendFrames(name)79+ s.sendFrames(name, flipped[name])
80 }80 }
81 if s.releaseHeld(time.Now(), reports) && idle {81 if s.releaseHeld(time.Now(), reports) && idle {
82 // Held buffers wait for a flip or heldTimeout.82 // Held buffers wait for a flip or heldTimeout.
@@ -186,7 +186,13 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
186 return fire, max(wait, time.Millisecond), !waiting186 return fire, max(wait, time.Millisecond), !waiting
187 }187 }
188 188
189-func (s *Server) sendFrames(name string) {189+// paces reports whether a report paces frame callbacks: a flip of a real
190+// display. Software flips (headless) answer presentation feedback, but
191+// callbacks keep the output's refresh period.
192+func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.HardwareClock }
193+
194+// sendFrames fires an output's callbacks; flipped: a flip of it fired them.
195+func (s *Server) sendFrames(name string, flipped bool) {
190 callbacks := s.awaiting[name]196 callbacks := s.awaiting[name]
191 delete(s.awaiting, name)197 delete(s.awaiting, name)
192 if len(callbacks) == 0 {198 if len(callbacks) == 0 {
@@ -195,7 +201,7 @@ func (s *Server) sendFrames(name string) {
195 // done carries the flip's time when the output flips (the frame the201 // done carries the flip's time when the output flips (the frame the
196 // client drew for is on screen), else now.202 // client drew for is on screen), else now.
197 ms := uint32(time.Since(s.started).Milliseconds())203 ms := uint32(time.Since(s.started).Milliseconds())
198- if f, ok := s.flips[name]; ok && f.When > 0 {204+ if f, ok := s.flips[name]; ok && flipped && f.When > 0 {
199 ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())205 ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())
200 }206 }
201 for _, cb := range callbacks {207 for _, cb := range callbacks {
M internal/adapters/wayland/presentation.go +11 -10
@@ -19,14 +19,13 @@ import (
1919
2020 const presentationVersion = 2
2121
22-// feedbackWait is a committed feedback: presented once a flip on output
23-// shows content seq of window win.
22+// feedbackWait is a committed feedback: presented once a flip on the
23+// surface's output shows content seq of window win.
2424 type feedbackWait struct {
2525 fb *presentationtime.WpPresentationFeedback
2626 surf *surface
2727 win ports.WindowID
2828 seq uint64
29- output string
3029 at time.Time
3130 // replaced is the window content Seq of the surface's next commit (0:
3231 // none yet): a flip showing it or later shows the replacement.
@@ -69,12 +68,13 @@ func (feedbackHandler) Destroy(*presentationtime.WpPresentationFeedback) {}
6968
7069 // commitFeedback moves a surface's pending feedbacks to its window's
7170 // content just emitted, or discards them when the surface shows nowhere.
72-// The commit replaces the surface's earlier waiting feedbacks.
73-func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedback) {
71+// A commit with a new buffer (fresh) replaces the surface's earlier
72+// waiting feedbacks.
73+func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedback, fresh bool) {
7474 srv := s.server
7575 root := s.root()
7676 win := root.windowID()
77- if win != 0 {
77+ if win != 0 && fresh {
7878 srv.contentMu.Lock()
7979 seq := srv.contentSeq[win]
8080 srv.contentMu.Unlock()
@@ -98,7 +98,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
9898 seq := srv.contentSeq[win]
9999 srv.contentMu.Unlock()
100100 for _, fb := range pending {
101- srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, output: output, at: time.Now()})
101+ srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, at: time.Now()})
102102 }
103103 }
104104
@@ -124,7 +124,7 @@ func (s *Server) presentFlip(output string, f *ports.FlipInfo) {
124124 case !w.fb.Resource.Alive():
125125 case w.surf.destroyed:
126126 discard(w.fb)
127- case w.output != output || !ok || shown < w.seq:
127+ case s.frameOutput(w.surf) != output || !ok || shown < w.seq:
128128 kept = append(kept, w)
129129 case f.Merged > 0:
130130 // The reader fell behind: which flip showed it is unknown.
@@ -154,7 +154,8 @@ func (s *Server) sendPresented(w feedbackWait, o *output, f *ports.FlipInfo, exa
154154 }
155155 }
156156 flags := uint32(0)
157- if !f.Async {
157+ // A software clock (headless) claims nothing about the display.
158+ if !f.Async && f.HardwareClock {
158159 flags |= uint32(presentationtime.WpPresentationFeedbackKindVsync)
159160 }
160161 if f.HardwareClock {
@@ -181,7 +182,7 @@ func (s *Server) dropFeedbacks(now time.Time) {
181182 if !w.fb.Resource.Alive() {
182183 continue
183184 }
184- if w.surf.destroyed || s.outputByNameExact(w.output) == nil || now.Sub(w.at) > feedbackTimeout {
185+ if w.surf.destroyed || s.frameOutput(w.surf) == "" || now.Sub(w.at) > feedbackTimeout {
185186 discard(w.fb)
186187 continue
187188 }
@@ -19,14 +19,13 @@ import (
19 19
20 const presentationVersion = 220 const presentationVersion = 2
21 21
22-// feedbackWait is a committed feedback: presented once a flip on output22+// feedbackWait is a committed feedback: presented once a flip on the
23-// shows content seq of window win.23+// surface's output shows content seq of window win.
24 type feedbackWait struct {24 type feedbackWait struct {
25 fb *presentationtime.WpPresentationFeedback25 fb *presentationtime.WpPresentationFeedback
26 surf *surface26 surf *surface
27 win ports.WindowID27 win ports.WindowID
28 seq uint6428 seq uint64
29- output string
30 at time.Time29 at time.Time
31 // replaced is the window content Seq of the surface's next commit (0:30 // replaced is the window content Seq of the surface's next commit (0:
32 // none yet): a flip showing it or later shows the replacement.31 // none yet): a flip showing it or later shows the replacement.
@@ -69,12 +68,13 @@ func (feedbackHandler) Destroy(*presentationtime.WpPresentationFeedback) {}
69 68
70 // commitFeedback moves a surface's pending feedbacks to its window's69 // commitFeedback moves a surface's pending feedbacks to its window's
71 // content just emitted, or discards them when the surface shows nowhere.70 // content just emitted, or discards them when the surface shows nowhere.
72-// The commit replaces the surface's earlier waiting feedbacks.71+// A commit with a new buffer (fresh) replaces the surface's earlier
73-func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedback) {72+// waiting feedbacks.
73+func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedback, fresh bool) {
74 srv := s.server74 srv := s.server
75 root := s.root()75 root := s.root()
76 win := root.windowID()76 win := root.windowID()
77- if win != 0 {77+ if win != 0 && fresh {
78 srv.contentMu.Lock()78 srv.contentMu.Lock()
79 seq := srv.contentSeq[win]79 seq := srv.contentSeq[win]
80 srv.contentMu.Unlock()80 srv.contentMu.Unlock()
@@ -98,7 +98,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
98 seq := srv.contentSeq[win]98 seq := srv.contentSeq[win]
99 srv.contentMu.Unlock()99 srv.contentMu.Unlock()
100 for _, fb := range pending {100 for _, fb := range pending {
101- srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, output: output, at: time.Now()})101+ srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, at: time.Now()})
102 }102 }
103 }103 }
104 104
@@ -124,7 +124,7 @@ func (s *Server) presentFlip(output string, f *ports.FlipInfo) {
124 case !w.fb.Resource.Alive():124 case !w.fb.Resource.Alive():
125 case w.surf.destroyed:125 case w.surf.destroyed:
126 discard(w.fb)126 discard(w.fb)
127- case w.output != output || !ok || shown < w.seq:127+ case s.frameOutput(w.surf) != output || !ok || shown < w.seq:
128 kept = append(kept, w)128 kept = append(kept, w)
129 case f.Merged > 0:129 case f.Merged > 0:
130 // The reader fell behind: which flip showed it is unknown.130 // The reader fell behind: which flip showed it is unknown.
@@ -154,7 +154,8 @@ func (s *Server) sendPresented(w feedbackWait, o *output, f *ports.FlipInfo, exa
154 }154 }
155 }155 }
156 flags := uint32(0)156 flags := uint32(0)
157- if !f.Async {157+ // A software clock (headless) claims nothing about the display.
158+ if !f.Async && f.HardwareClock {
158 flags |= uint32(presentationtime.WpPresentationFeedbackKindVsync)159 flags |= uint32(presentationtime.WpPresentationFeedbackKindVsync)
159 }160 }
160 if f.HardwareClock {161 if f.HardwareClock {
@@ -181,7 +182,7 @@ func (s *Server) dropFeedbacks(now time.Time) {
181 if !w.fb.Resource.Alive() {182 if !w.fb.Resource.Alive() {
182 continue183 continue
183 }184 }
184- if w.surf.destroyed || s.outputByNameExact(w.output) == nil || now.Sub(w.at) > feedbackTimeout {185+ if w.surf.destroyed || s.frameOutput(w.surf) == "" || now.Sub(w.at) > feedbackTimeout {
185 discard(w.fb)186 discard(w.fb)
186 continue187 continue
187 }188 }
M internal/adapters/wayland/presentation_test.go +11 -0
@@ -271,3 +271,14 @@ func TestNextRefreshFollowsFlips(t *testing.T) {
271271 t.Fatalf("next refresh in %v, want %v", d, period*3/4)
272272 }
273273 }
274+
275+// Software flips (headless) answer feedback without flags and do not pace
276+// frame callbacks.
277+func TestSoftwareFlipsDoNotPace(t *testing.T) {
278+ if paces(ports.OutputPresented{Flip: &ports.FlipInfo{}}) {
279+ t.Fatal("software flip paces callbacks")
280+ }
281+ if !paces(ports.OutputPresented{Flip: &ports.FlipInfo{HardwareClock: true}}) {
282+ t.Fatal("display flip does not pace")
283+ }
284+}
@@ -271,3 +271,14 @@ func TestNextRefreshFollowsFlips(t *testing.T) {
271 t.Fatalf("next refresh in %v, want %v", d, period*3/4)271 t.Fatalf("next refresh in %v, want %v", d, period*3/4)
272 }272 }
273 }273 }
274+
275+// Software flips (headless) answer feedback without flags and do not pace
276+// frame callbacks.
277+func TestSoftwareFlipsDoNotPace(t *testing.T) {
278+ if paces(ports.OutputPresented{Flip: &ports.FlipInfo{}}) {
279+ t.Fatal("software flip paces callbacks")
280+ }
281+ if !paces(ports.OutputPresented{Flip: &ports.FlipInfo{HardwareClock: true}}) {
282+ t.Fatal("display flip does not pace")
283+ }
284+}
M internal/adapters/wayland/surface.go +3 -2
@@ -340,7 +340,8 @@ func (s *surface) applyCommit() {
340340 if s.xdg != nil && s.xdg.window != nil {
341341 s.xdg.window.afterCommit()
342342 }
343- if fresh || moved || geometry || hinted || s.sub.parent != nil {
343+ drawn := fresh || moved || geometry || hinted || s.sub.parent != nil
344+ if drawn {
344345 if moved || geometry {
345346 s.committed = damage{full: true}
346347 }
@@ -348,7 +349,7 @@ func (s *surface) applyCommit() {
348349 }
349350 fb := s.pendingFeedback
350351 s.pendingFeedback = nil
351- s.commitFeedback(fb)
352+ s.commitFeedback(fb, fresh)
352353 }
353354 func (s *surface) Damage(_ *wayland.Surface, x, y, w, h int32) {
354355 if w > 0 && h > 0 && len(s.pendingDamage) <= maxDamageRects {
@@ -340,7 +340,8 @@ func (s *surface) applyCommit() {
340 if s.xdg != nil && s.xdg.window != nil {340 if s.xdg != nil && s.xdg.window != nil {
341 s.xdg.window.afterCommit()341 s.xdg.window.afterCommit()
342 }342 }
343- if fresh || moved || geometry || hinted || s.sub.parent != nil {343+ drawn := fresh || moved || geometry || hinted || s.sub.parent != nil
344+ if drawn {
344 if moved || geometry {345 if moved || geometry {
345 s.committed = damage{full: true}346 s.committed = damage{full: true}
346 }347 }
@@ -348,7 +349,7 @@ func (s *surface) applyCommit() {
348 }349 }
349 fb := s.pendingFeedback350 fb := s.pendingFeedback
350 s.pendingFeedback = nil351 s.pendingFeedback = nil
351- s.commitFeedback(fb)352+ s.commitFeedback(fb, fresh)
352 }353 }
353 func (s *surface) Damage(_ *wayland.Surface, x, y, w, h int32) {354 func (s *surface) Damage(_ *wayland.Surface, x, y, w, h int32) {
354 if w > 0 && h > 0 && len(s.pendingDamage) <= maxDamageRects {355 if w > 0 && h > 0 && len(s.pendingDamage) <= maxDamageRects {

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