detached f0db5a6

feat(wayland): wp_presentation feedback from kernel flip timestamps, flip-based frame times and commit timing

Feedbacks attach to a surface's commit and wait for the flip that shows their window's content: presented with that flip's CLOCK_MONOTONIC time, vblank counter, refresh (0 under VRR) and vsync/hw_clock/hw_completion/zero_copy flags, sync_output first; discarded when the surface committed again before, was destroyed, shows nowhere, its output left, or after a second without a flip. Each flip is handled on its own. Frame callback done carries the flip time, and commit timing targets the next vblank counted from the last flip instead of now plus a period. Headless frames report flips at the software clock.
commit
f0db5a64ce7f31a0a10a0cec5ef3d23f94b8e257
author
brice · · 2026-09-26 12:05 UTC
parent
979e3cd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +571 -10
M internal/adapters/headless/output.go +26 -4
@@ -3,6 +3,7 @@ package headless
33 import (
44 "context"
55 "fmt"
6+ "golang.org/x/sys/unix"
67 "image"
78 "image/png"
89 "maps"
@@ -136,7 +137,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
136137 }
137138 }
138139 frame++
139- pending = opts.report(pending, seen)
140+ pending = opts.flipped(pending, seen, scene, surfaces)
140141 if opts.ScreenshotDir != "" {
141142 shot := r.Pixels()
142143 if opts.Cursor != nil {
@@ -169,10 +170,8 @@ func writePNG(path string, img *image.RGBA) error {
169170 return os.Rename(f.Name(), path)
170171 }
171172
172-// report sends what the output read; a full channel drops it.
173173 // report sends what the output read, or returns it to retry when the
174-// channel is full. Headless frames are not paced by a screen: Flip stays
175-// false and frame callbacks follow the refresh timer.
174+// channel is full. Frames that were not drawn carry no flip.
176175 func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {
177176 if opts.Presented == nil {
178177 return nil
@@ -180,6 +179,29 @@ func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uin
180179 return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})
181180 }
182181
182+// flipped reports a drawn frame as a flip at the current CLOCK_MONOTONIC
183+// time (software clock, refresh unknown), so presentation feedback and
184+// frame callbacks follow headless frames. A report still waiting is
185+// sent first.
186+func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.WindowID]uint64, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.OutputPresented {
187+ if opts.Presented == nil {
188+ return nil
189+ }
190+ if pending != nil && opts.send(pending) != nil {
191+ // The reader is behind: this frame's report replaces the waiting one.
192+ pending = nil
193+ }
194+ var ts unix.Timespec
195+ _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
196+ shows := map[ports.WindowID]uint64{}
197+ for id, c := range surfaces {
198+ if scene.Shows(id) {
199+ shows[id] = c.Seq
200+ }
201+ }
202+ return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}})
203+}
204+
183205 func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
184206 select {
185207 case opts.Presented <- *r:
@@ -3,6 +3,7 @@ package headless
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "golang.org/x/sys/unix"
6 "image"7 "image"
7 "image/png"8 "image/png"
8 "maps"9 "maps"
@@ -136,7 +137,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
136 }137 }
137 }138 }
138 frame++139 frame++
139- pending = opts.report(pending, seen)140+ pending = opts.flipped(pending, seen, scene, surfaces)
140 if opts.ScreenshotDir != "" {141 if opts.ScreenshotDir != "" {
141 shot := r.Pixels()142 shot := r.Pixels()
142 if opts.Cursor != nil {143 if opts.Cursor != nil {
@@ -169,10 +170,8 @@ func writePNG(path string, img *image.RGBA) error {
169 return os.Rename(f.Name(), path)170 return os.Rename(f.Name(), path)
170 }171 }
171 172
172-// report sends what the output read; a full channel drops it.
173 // report sends what the output read, or returns it to retry when the173 // report sends what the output read, or returns it to retry when the
174-// channel is full. Headless frames are not paced by a screen: Flip stays174+// channel is full. Frames that were not drawn carry no flip.
175-// false and frame callbacks follow the refresh timer.
176 func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {175 func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {
177 if opts.Presented == nil {176 if opts.Presented == nil {
178 return nil177 return nil
@@ -180,6 +179,29 @@ func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uin
180 return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})179 return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})
181 }180 }
182 181
182+// flipped reports a drawn frame as a flip at the current CLOCK_MONOTONIC
183+// time (software clock, refresh unknown), so presentation feedback and
184+// frame callbacks follow headless frames. A report still waiting is
185+// sent first.
186+func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.WindowID]uint64, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.OutputPresented {
187+ if opts.Presented == nil {
188+ return nil
189+ }
190+ if pending != nil && opts.send(pending) != nil {
191+ // The reader is behind: this frame's report replaces the waiting one.
192+ pending = nil
193+ }
194+ var ts unix.Timespec
195+ _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
196+ shows := map[ports.WindowID]uint64{}
197+ for id, c := range surfaces {
198+ if scene.Shows(id) {
199+ shows[id] = c.Seq
200+ }
201+ }
202+ return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}})
203+}
204+
183 func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {205 func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
184 select {206 select {
185 case opts.Presented <- *r:207 case opts.Presented <- *r:
M internal/adapters/wayland/fifo.go +29 -4
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/purego-libwayland/protocol/committiming"
88 "github.com/bnema/purego-libwayland/protocol/contenttype"
99 "github.com/bnema/purego-libwayland/protocol/fifo"
10+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
1011 "github.com/bnema/purego-libwayland/protocol/wayland"
1112 "github.com/bnema/purego-libwayland/server"
1213 "golang.org/x/sys/unix"
@@ -24,7 +25,7 @@ import (
2425 // queued; the pacer (frames.go) applies queued updates in order when they
2526 // are ready. wp_content_type_v1 is recorded and logged only.
2627
27-func registerPresentation(d *server.Display, s *Server) error {
28+func registerPresentationConstraints(d *server.Display, s *Server) error {
2829 if err := fifo.NewWpFifoManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
2930 _, _ = fifo.NewWpFifoManagerV1(c, int32(v), id, fifoManager{s})
3031 }); err != nil {
@@ -216,6 +217,7 @@ type update struct {
216217 barrier bool
217218 wait bool
218219 at time.Time
220+ feedback []*presentationtime.WpPresentationFeedback
219221 damage []ports.Rect
220222 bufDamage []ports.Rect
221223 }
@@ -228,8 +230,9 @@ type childMove struct {
228230 // takePending moves the pending state into an update: one-shot state is
229231 // cleared, sticky state (scale, hints, viewport, geometry, layer) kept.
230232 func (s *surface) takePending() update {
231- u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage}
233+ u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage, feedback: s.pendingFeedback}
232234 s.attached, s.pending, s.callbacks = false, nil, nil
235+ s.pendingFeedback = nil
233236 s.pendingDamage, s.pendingBufDamage = nil, nil
234237 s.pendingBarrier, s.pendingWait, s.pendingTime = false, false, time.Time{}
235238 if v := s.viewport; v != nil {
@@ -260,6 +263,7 @@ func (s *surface) putPending(u update) {
260263 s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind
261264 s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at
262265 s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage
266+ s.pendingFeedback = u.feedback
263267 if u.vp != nil && s.viewport == u.vp {
264268 u.vp.pendingW, u.vp.pendingH, u.vp.pendingSet = u.vpW, u.vpH, u.vpSet
265269 }
@@ -287,7 +291,25 @@ func (s *surface) mustWait(now time.Time) bool {
287291 // tooEarly reports whether content applied now would show before at:
288292 // it reaches the screen one refresh later.
289293 func (s *surface) tooEarly(at, now time.Time) bool {
290- return !at.IsZero() && at.After(now.Add(s.server.outputPeriod(s.server.fifoOutput(s))))
294+ return !at.IsZero() && at.After(s.server.nextRefresh(s.server.fifoOutput(s), now))
295+}
296+
297+// nextRefresh is when the output's next frame reaches the screen: the
298+// vblank after now counted from its last flip (kernel time, refresh
299+// period), or one period from now without flips or while VRR is on.
300+func (s *Server) nextRefresh(name string, now time.Time) time.Time {
301+ p := s.outputPeriod(name)
302+ f, ok := s.flips[name]
303+ if !ok || f.When <= 0 || f.Refresh <= 0 {
304+ return now.Add(p)
305+ }
306+ mono := monotonic(now)
307+ since := mono - f.When
308+ if since < 0 || since > time.Second {
309+ return now.Add(p)
310+ }
311+ n := since/f.Refresh + 1
312+ return now.Add(f.When + n*f.Refresh - mono)
291313 }
292314
293315 // queueUpdate queues the pending commit until it can apply.
@@ -322,6 +344,9 @@ func (s *surface) dropQueue() {
322344 } else if len(u.callbacks) > 0 {
323345 s.server.queueFrames(s.server.frameOutput(s), u.callbacks)
324346 }
347+ for _, fb := range u.feedback {
348+ discard(fb)
349+ }
325350 if u.buffer != nil && !sameBuffer(u.buffer, s.current) && !released[u.buffer.Resource] && u.buffer.Resource.Alive() {
326351 released[u.buffer.Resource] = true
327352 u.buffer.SendRelease()
@@ -389,7 +414,7 @@ func (s *Server) tickFifo(now time.Time, flipped map[string]bool) (time.Duration
389414 break
390415 }
391416 if surf.tooEarly(u.at, now) {
392- wait = min(wait, u.at.Add(-p).Sub(now))
417+ wait = min(wait, u.at.Sub(s.nextRefresh(name, now))+time.Millisecond)
393418 break
394419 }
395420 surf.queue = surf.queue[1:]
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/purego-libwayland/protocol/committiming"7 "github.com/bnema/purego-libwayland/protocol/committiming"
8 "github.com/bnema/purego-libwayland/protocol/contenttype"8 "github.com/bnema/purego-libwayland/protocol/contenttype"
9 "github.com/bnema/purego-libwayland/protocol/fifo"9 "github.com/bnema/purego-libwayland/protocol/fifo"
10+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
10 "github.com/bnema/purego-libwayland/protocol/wayland"11 "github.com/bnema/purego-libwayland/protocol/wayland"
11 "github.com/bnema/purego-libwayland/server"12 "github.com/bnema/purego-libwayland/server"
12 "golang.org/x/sys/unix"13 "golang.org/x/sys/unix"
@@ -24,7 +25,7 @@ import (
24 // queued; the pacer (frames.go) applies queued updates in order when they25 // queued; the pacer (frames.go) applies queued updates in order when they
25 // are ready. wp_content_type_v1 is recorded and logged only.26 // are ready. wp_content_type_v1 is recorded and logged only.
26 27
27-func registerPresentation(d *server.Display, s *Server) error {28+func registerPresentationConstraints(d *server.Display, s *Server) error {
28 if err := fifo.NewWpFifoManagerV1Global(d, 1, func(c server.Client, v, id uint32) {29 if err := fifo.NewWpFifoManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
29 _, _ = fifo.NewWpFifoManagerV1(c, int32(v), id, fifoManager{s})30 _, _ = fifo.NewWpFifoManagerV1(c, int32(v), id, fifoManager{s})
30 }); err != nil {31 }); err != nil {
@@ -216,6 +217,7 @@ type update struct {
216 barrier bool217 barrier bool
217 wait bool218 wait bool
218 at time.Time219 at time.Time
220+ feedback []*presentationtime.WpPresentationFeedback
219 damage []ports.Rect221 damage []ports.Rect
220 bufDamage []ports.Rect222 bufDamage []ports.Rect
221 }223 }
@@ -228,8 +230,9 @@ type childMove struct {
228 // takePending moves the pending state into an update: one-shot state is230 // takePending moves the pending state into an update: one-shot state is
229 // cleared, sticky state (scale, hints, viewport, geometry, layer) kept.231 // cleared, sticky state (scale, hints, viewport, geometry, layer) kept.
230 func (s *surface) takePending() update {232 func (s *surface) takePending() update {
231- u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage}233+ u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage, feedback: s.pendingFeedback}
232 s.attached, s.pending, s.callbacks = false, nil, nil234 s.attached, s.pending, s.callbacks = false, nil, nil
235+ s.pendingFeedback = nil
233 s.pendingDamage, s.pendingBufDamage = nil, nil236 s.pendingDamage, s.pendingBufDamage = nil, nil
234 s.pendingBarrier, s.pendingWait, s.pendingTime = false, false, time.Time{}237 s.pendingBarrier, s.pendingWait, s.pendingTime = false, false, time.Time{}
235 if v := s.viewport; v != nil {238 if v := s.viewport; v != nil {
@@ -260,6 +263,7 @@ func (s *surface) putPending(u update) {
260 s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind263 s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind
261 s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at264 s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at
262 s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage265 s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage
266+ s.pendingFeedback = u.feedback
263 if u.vp != nil && s.viewport == u.vp {267 if u.vp != nil && s.viewport == u.vp {
264 u.vp.pendingW, u.vp.pendingH, u.vp.pendingSet = u.vpW, u.vpH, u.vpSet268 u.vp.pendingW, u.vp.pendingH, u.vp.pendingSet = u.vpW, u.vpH, u.vpSet
265 }269 }
@@ -287,7 +291,25 @@ func (s *surface) mustWait(now time.Time) bool {
287 // tooEarly reports whether content applied now would show before at:291 // tooEarly reports whether content applied now would show before at:
288 // it reaches the screen one refresh later.292 // it reaches the screen one refresh later.
289 func (s *surface) tooEarly(at, now time.Time) bool {293 func (s *surface) tooEarly(at, now time.Time) bool {
290- return !at.IsZero() && at.After(now.Add(s.server.outputPeriod(s.server.fifoOutput(s))))294+ return !at.IsZero() && at.After(s.server.nextRefresh(s.server.fifoOutput(s), now))
295+}
296+
297+// nextRefresh is when the output's next frame reaches the screen: the
298+// vblank after now counted from its last flip (kernel time, refresh
299+// period), or one period from now without flips or while VRR is on.
300+func (s *Server) nextRefresh(name string, now time.Time) time.Time {
301+ p := s.outputPeriod(name)
302+ f, ok := s.flips[name]
303+ if !ok || f.When <= 0 || f.Refresh <= 0 {
304+ return now.Add(p)
305+ }
306+ mono := monotonic(now)
307+ since := mono - f.When
308+ if since < 0 || since > time.Second {
309+ return now.Add(p)
310+ }
311+ n := since/f.Refresh + 1
312+ return now.Add(f.When + n*f.Refresh - mono)
291 }313 }
292 314
293 // queueUpdate queues the pending commit until it can apply.315 // queueUpdate queues the pending commit until it can apply.
@@ -322,6 +344,9 @@ func (s *surface) dropQueue() {
322 } else if len(u.callbacks) > 0 {344 } else if len(u.callbacks) > 0 {
323 s.server.queueFrames(s.server.frameOutput(s), u.callbacks)345 s.server.queueFrames(s.server.frameOutput(s), u.callbacks)
324 }346 }
347+ for _, fb := range u.feedback {
348+ discard(fb)
349+ }
325 if u.buffer != nil && !sameBuffer(u.buffer, s.current) && !released[u.buffer.Resource] && u.buffer.Resource.Alive() {350 if u.buffer != nil && !sameBuffer(u.buffer, s.current) && !released[u.buffer.Resource] && u.buffer.Resource.Alive() {
326 released[u.buffer.Resource] = true351 released[u.buffer.Resource] = true
327 u.buffer.SendRelease()352 u.buffer.SendRelease()
@@ -389,7 +414,7 @@ func (s *Server) tickFifo(now time.Time, flipped map[string]bool) (time.Duration
389 break414 break
390 }415 }
391 if surf.tooEarly(u.at, now) {416 if surf.tooEarly(u.at, now) {
392- wait = min(wait, u.at.Add(-p).Sub(now))417+ wait = min(wait, u.at.Sub(s.nextRefresh(name, now))+time.Millisecond)
393 break418 break
394 }419 }
395 surf.queue = surf.queue[1:]420 surf.queue = surf.queue[1:]
M internal/adapters/wayland/frames.go +34 -1
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "context"
5+ "golang.org/x/sys/unix"
56 "slices"
67 "time"
78
@@ -54,8 +55,26 @@ func (s *Server) pace(ctx context.Context) {
5455 wait, idle := defaultFramePeriod, false
5556 if !s.display.Do(func() {
5657 if ctx.Err() == nil {
58+ // Each flip answers its own presentation feedbacks, in
59+ // order: flips are never merged here.
60+ for _, p := range reports {
61+ if p.Flip != nil {
62+ s.flips[p.Output] = *p.Flip
63+ s.presentFlip(p.Output, p.Flip)
64+ }
65+ }
66+ s.dropFeedbacks(time.Now())
67+ if len(s.feedbacks) > 0 {
68+ wait = feedbackTimeout
69+ }
5770 var due []string
58- due, wait, idle = s.dueFrames(time.Now(), flipped)
71+ var dueWait time.Duration
72+ due, dueWait, idle = s.dueFrames(time.Now(), flipped)
73+ if len(s.feedbacks) > 0 {
74+ // Unanswered feedbacks time out: keep ticking.
75+ dueWait, idle = min(dueWait, feedbackTimeout), false
76+ }
77+ wait = dueWait
5978 for _, name := range due {
6079 s.sendFrames(name)
6180 }
@@ -173,7 +192,12 @@ func (s *Server) sendFrames(name string) {
173192 if len(callbacks) == 0 {
174193 return
175194 }
195+ // done carries the flip's time when the output flips (the frame the
196+ // client drew for is on screen), else now.
176197 ms := uint32(time.Since(s.started).Milliseconds())
198+ if f, ok := s.flips[name]; ok && f.When > 0 {
199+ ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())
200+ }
177201 for _, cb := range callbacks {
178202 if !cb.Resource.Alive() {
179203 continue
@@ -191,3 +215,12 @@ func framePeriod(refreshMilli int) time.Duration {
191215 }
192216 return time.Duration(int64(time.Second) * 1000 / int64(refreshMilli))
193217 }
218+
219+// monotonic is t on CLOCK_MONOTONIC, the clock of flip timestamps.
220+func monotonic(t time.Time) time.Duration {
221+ var ts unix.Timespec
222+ if unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) != nil {
223+ return 0
224+ }
225+ return time.Duration(ts.Nano()) - time.Since(t)
226+}
@@ -2,6 +2,7 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "golang.org/x/sys/unix"
5 "slices"6 "slices"
6 "time"7 "time"
7 8
@@ -54,8 +55,26 @@ func (s *Server) pace(ctx context.Context) {
54 wait, idle := defaultFramePeriod, false55 wait, idle := defaultFramePeriod, false
55 if !s.display.Do(func() {56 if !s.display.Do(func() {
56 if ctx.Err() == nil {57 if ctx.Err() == nil {
58+ // Each flip answers its own presentation feedbacks, in
59+ // order: flips are never merged here.
60+ for _, p := range reports {
61+ if p.Flip != nil {
62+ s.flips[p.Output] = *p.Flip
63+ s.presentFlip(p.Output, p.Flip)
64+ }
65+ }
66+ s.dropFeedbacks(time.Now())
67+ if len(s.feedbacks) > 0 {
68+ wait = feedbackTimeout
69+ }
57 var due []string70 var due []string
58- due, wait, idle = s.dueFrames(time.Now(), flipped)71+ var dueWait time.Duration
72+ due, dueWait, idle = s.dueFrames(time.Now(), flipped)
73+ if len(s.feedbacks) > 0 {
74+ // Unanswered feedbacks time out: keep ticking.
75+ dueWait, idle = min(dueWait, feedbackTimeout), false
76+ }
77+ wait = dueWait
59 for _, name := range due {78 for _, name := range due {
60 s.sendFrames(name)79 s.sendFrames(name)
61 }80 }
@@ -173,7 +192,12 @@ func (s *Server) sendFrames(name string) {
173 if len(callbacks) == 0 {192 if len(callbacks) == 0 {
174 return193 return
175 }194 }
195+ // done carries the flip's time when the output flips (the frame the
196+ // client drew for is on screen), else now.
176 ms := uint32(time.Since(s.started).Milliseconds())197 ms := uint32(time.Since(s.started).Milliseconds())
198+ if f, ok := s.flips[name]; ok && f.When > 0 {
199+ ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())
200+ }
177 for _, cb := range callbacks {201 for _, cb := range callbacks {
178 if !cb.Resource.Alive() {202 if !cb.Resource.Alive() {
179 continue203 continue
@@ -191,3 +215,12 @@ func framePeriod(refreshMilli int) time.Duration {
191 }215 }
192 return time.Duration(int64(time.Second) * 1000 / int64(refreshMilli))216 return time.Duration(int64(time.Second) * 1000 / int64(refreshMilli))
193 }217 }
218+
219+// monotonic is t on CLOCK_MONOTONIC, the clock of flip timestamps.
220+func monotonic(t time.Time) time.Duration {
221+ var ts unix.Timespec
222+ if unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) != nil {
223+ return 0
224+ }
225+ return time.Duration(ts.Nano()) - time.Since(t)
226+}
M internal/adapters/wayland/globals.go +1 -0
@@ -18,6 +18,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1818 func() error { return registerDMABuf(d, s, o.DMABuf) },
1919 func() error { return registerPointerConstraints(d, s) },
2020 func() error { return registerTearing(d, s) },
21+ func() error { return registerPresentationConstraints(d, s) },
2122 func() error { return registerPresentation(d, s) },
2223 func() error { return registerActivation(d, s) },
2324 func() error { return registerVirtualKeyboard(d, s) },
@@ -18,6 +18,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
18 func() error { return registerDMABuf(d, s, o.DMABuf) },18 func() error { return registerDMABuf(d, s, o.DMABuf) },
19 func() error { return registerPointerConstraints(d, s) },19 func() error { return registerPointerConstraints(d, s) },
20 func() error { return registerTearing(d, s) },20 func() error { return registerTearing(d, s) },
21+ func() error { return registerPresentationConstraints(d, s) },
21 func() error { return registerPresentation(d, s) },22 func() error { return registerPresentation(d, s) },
22 func() error { return registerActivation(d, s) },23 func() error { return registerActivation(d, s) },
23 func() error { return registerVirtualKeyboard(d, s) },24 func() error { return registerVirtualKeyboard(d, s) },
A internal/adapters/wayland/presentation.go +192 -0
@@ -0,0 +1,192 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// wp_presentation: a client learns when each commit reached the screen.
14+// A feedback belongs to the commit it was requested before; on commit it
15+// waits for the flip that shows its window's content (FlipInfo.Shows)
16+// and gets that flip's kernel timestamp, vblank counter and refresh.
17+// A content replaced before any flip showed it, a destroyed surface or a
18+// surface on no output is discarded.
19+
20+const presentationVersion = 2
21+
22+// feedbackWait is a committed feedback: presented once a flip on output
23+// shows content seq of window win.
24+type feedbackWait struct {
25+ fb *presentationtime.WpPresentationFeedback
26+ surf *surface
27+ win ports.WindowID
28+ seq uint64
29+ output string
30+ at time.Time
31+ // 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.
33+ replaced uint64
34+}
35+
36+func registerPresentation(d *server.Display, s *Server) error {
37+ return presentationtime.NewWpPresentationGlobal(d, presentationVersion, func(c server.Client, v, id uint32) {
38+ r, err := presentationtime.NewWpPresentation(c, int32(v), id, presentation{s})
39+ if err == nil {
40+ r.SendClockId(unix.CLOCK_MONOTONIC)
41+ }
42+ })
43+}
44+
45+type presentation struct{ server *Server }
46+
47+func (presentation) Destroy(*presentationtime.WpPresentation) {}
48+
49+// Feedback attaches a feedback to the surface's next commit.
50+func (p presentation) Feedback(r *presentationtime.WpPresentation, surf *wayland.Surface, id uint32) {
51+ fb, err := presentationtime.NewWpPresentationFeedback(r.Client(), r.Version(), id, feedbackHandler{})
52+ if err != nil {
53+ return
54+ }
55+ state := p.server.surfaceOf(surf)
56+ if state == nil || state.destroyed {
57+ fb.SendDiscarded()
58+ fb.Destroy()
59+ return
60+ }
61+ state.pendingFeedback = append(state.pendingFeedback, fb)
62+}
63+
64+type feedbackHandler struct{}
65+
66+// Destroy is not a request of wp_presentation_feedback; the handler
67+// satisfies the generated interface.
68+func (feedbackHandler) Destroy(*presentationtime.WpPresentationFeedback) {}
69+
70+// commitFeedback moves a surface's pending feedbacks to its window's
71+// 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) {
74+ srv := s.server
75+ root := s.root()
76+ win := root.windowID()
77+ if win != 0 {
78+ srv.contentMu.Lock()
79+ seq := srv.contentSeq[win]
80+ srv.contentMu.Unlock()
81+ for i := range srv.feedbacks {
82+ if w := &srv.feedbacks[i]; w.surf == s && w.replaced == 0 && seq > w.seq {
83+ w.replaced = seq
84+ }
85+ }
86+ }
87+ if len(pending) == 0 {
88+ return
89+ }
90+ output := srv.frameOutput(s)
91+ if win == 0 || output == "" {
92+ for _, fb := range pending {
93+ discard(fb)
94+ }
95+ return
96+ }
97+ srv.contentMu.Lock()
98+ seq := srv.contentSeq[win]
99+ srv.contentMu.Unlock()
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()})
102+ }
103+}
104+
105+func discard(fb *presentationtime.WpPresentationFeedback) {
106+ if fb.Resource.Alive() {
107+ fb.SendDiscarded()
108+ fb.Destroy()
109+ }
110+}
111+
112+// presentFlip answers the feedbacks a flip of output answers: a window
113+// content at or after a feedback's is shown; the feedback is presented,
114+// or discarded when its surface committed again by then.
115+func (s *Server) presentFlip(output string, f *ports.FlipInfo) {
116+ if f == nil || len(s.feedbacks) == 0 {
117+ return
118+ }
119+ o := s.outputByNameExact(output)
120+ kept := s.feedbacks[:0]
121+ for _, w := range s.feedbacks {
122+ shown, ok := f.Shows[w.win]
123+ switch {
124+ case !w.fb.Resource.Alive():
125+ case w.surf.destroyed:
126+ discard(w.fb)
127+ case w.output != output || !ok || shown < w.seq:
128+ kept = append(kept, w)
129+ case f.Merged > 0:
130+ // The reader fell behind: which flip showed it is unknown.
131+ discard(w.fb)
132+ default:
133+ // Shown, unless the surface's next commit is shown too.
134+ s.sendPresented(w, o, f, w.replaced == 0 || shown < w.replaced)
135+ }
136+ }
137+ clear(s.feedbacks[len(kept):])
138+ s.feedbacks = kept
139+}
140+
141+// sendPresented sends presented for the flip; exact is false when a
142+// later commit of the surface replaced it before the flip (then it is
143+// discarded).
144+func (s *Server) sendPresented(w feedbackWait, o *output, f *ports.FlipInfo, exact bool) {
145+ if !exact {
146+ discard(w.fb)
147+ return
148+ }
149+ if o != nil {
150+ for _, r := range o.resources {
151+ if r.Resource.Alive() && r.Client() == w.fb.Client() {
152+ w.fb.SendSyncOutput(r)
153+ }
154+ }
155+ }
156+ flags := uint32(0)
157+ if !f.Async {
158+ flags |= uint32(presentationtime.WpPresentationFeedbackKindVsync)
159+ }
160+ if f.HardwareClock {
161+ flags |= uint32(presentationtime.WpPresentationFeedbackKindHwClock | presentationtime.WpPresentationFeedbackKindHwCompletion)
162+ }
163+ if f.ZeroCopy {
164+ flags |= uint32(presentationtime.WpPresentationFeedbackKindZeroCopy)
165+ }
166+ sec, nsec := uint64(f.When/time.Second), uint32(f.When%time.Second)
167+ w.fb.SendPresented(uint32(sec>>32), uint32(sec), nsec, uint32(f.Refresh), uint32(f.Seq>>32), uint32(f.Seq), flags)
168+ w.fb.Destroy()
169+}
170+
171+// feedbackTimeout discards a feedback no flip answered: its window is
172+// hidden, on an output that does not flip (headless, switched away), or
173+// was never drawn.
174+const feedbackTimeout = time.Second
175+
176+// dropFeedbacks discards the feedbacks of destroyed surfaces, of outputs
177+// that are gone, and those older than feedbackTimeout.
178+func (s *Server) dropFeedbacks(now time.Time) {
179+ kept := s.feedbacks[:0]
180+ for _, w := range s.feedbacks {
181+ if !w.fb.Resource.Alive() {
182+ continue
183+ }
184+ if w.surf.destroyed || s.outputByNameExact(w.output) == nil || now.Sub(w.at) > feedbackTimeout {
185+ discard(w.fb)
186+ continue
187+ }
188+ kept = append(kept, w)
189+ }
190+ clear(s.feedbacks[len(kept):])
191+ s.feedbacks = kept
192+}
@@ -0,0 +1,192 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// wp_presentation: a client learns when each commit reached the screen.
14+// A feedback belongs to the commit it was requested before; on commit it
15+// waits for the flip that shows its window's content (FlipInfo.Shows)
16+// and gets that flip's kernel timestamp, vblank counter and refresh.
17+// A content replaced before any flip showed it, a destroyed surface or a
18+// surface on no output is discarded.
19+
20+const presentationVersion = 2
21+
22+// feedbackWait is a committed feedback: presented once a flip on output
23+// shows content seq of window win.
24+type feedbackWait struct {
25+ fb *presentationtime.WpPresentationFeedback
26+ surf *surface
27+ win ports.WindowID
28+ seq uint64
29+ output string
30+ at time.Time
31+ // 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.
33+ replaced uint64
34+}
35+
36+func registerPresentation(d *server.Display, s *Server) error {
37+ return presentationtime.NewWpPresentationGlobal(d, presentationVersion, func(c server.Client, v, id uint32) {
38+ r, err := presentationtime.NewWpPresentation(c, int32(v), id, presentation{s})
39+ if err == nil {
40+ r.SendClockId(unix.CLOCK_MONOTONIC)
41+ }
42+ })
43+}
44+
45+type presentation struct{ server *Server }
46+
47+func (presentation) Destroy(*presentationtime.WpPresentation) {}
48+
49+// Feedback attaches a feedback to the surface's next commit.
50+func (p presentation) Feedback(r *presentationtime.WpPresentation, surf *wayland.Surface, id uint32) {
51+ fb, err := presentationtime.NewWpPresentationFeedback(r.Client(), r.Version(), id, feedbackHandler{})
52+ if err != nil {
53+ return
54+ }
55+ state := p.server.surfaceOf(surf)
56+ if state == nil || state.destroyed {
57+ fb.SendDiscarded()
58+ fb.Destroy()
59+ return
60+ }
61+ state.pendingFeedback = append(state.pendingFeedback, fb)
62+}
63+
64+type feedbackHandler struct{}
65+
66+// Destroy is not a request of wp_presentation_feedback; the handler
67+// satisfies the generated interface.
68+func (feedbackHandler) Destroy(*presentationtime.WpPresentationFeedback) {}
69+
70+// commitFeedback moves a surface's pending feedbacks to its window's
71+// 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) {
74+ srv := s.server
75+ root := s.root()
76+ win := root.windowID()
77+ if win != 0 {
78+ srv.contentMu.Lock()
79+ seq := srv.contentSeq[win]
80+ srv.contentMu.Unlock()
81+ for i := range srv.feedbacks {
82+ if w := &srv.feedbacks[i]; w.surf == s && w.replaced == 0 && seq > w.seq {
83+ w.replaced = seq
84+ }
85+ }
86+ }
87+ if len(pending) == 0 {
88+ return
89+ }
90+ output := srv.frameOutput(s)
91+ if win == 0 || output == "" {
92+ for _, fb := range pending {
93+ discard(fb)
94+ }
95+ return
96+ }
97+ srv.contentMu.Lock()
98+ seq := srv.contentSeq[win]
99+ srv.contentMu.Unlock()
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()})
102+ }
103+}
104+
105+func discard(fb *presentationtime.WpPresentationFeedback) {
106+ if fb.Resource.Alive() {
107+ fb.SendDiscarded()
108+ fb.Destroy()
109+ }
110+}
111+
112+// presentFlip answers the feedbacks a flip of output answers: a window
113+// content at or after a feedback's is shown; the feedback is presented,
114+// or discarded when its surface committed again by then.
115+func (s *Server) presentFlip(output string, f *ports.FlipInfo) {
116+ if f == nil || len(s.feedbacks) == 0 {
117+ return
118+ }
119+ o := s.outputByNameExact(output)
120+ kept := s.feedbacks[:0]
121+ for _, w := range s.feedbacks {
122+ shown, ok := f.Shows[w.win]
123+ switch {
124+ case !w.fb.Resource.Alive():
125+ case w.surf.destroyed:
126+ discard(w.fb)
127+ case w.output != output || !ok || shown < w.seq:
128+ kept = append(kept, w)
129+ case f.Merged > 0:
130+ // The reader fell behind: which flip showed it is unknown.
131+ discard(w.fb)
132+ default:
133+ // Shown, unless the surface's next commit is shown too.
134+ s.sendPresented(w, o, f, w.replaced == 0 || shown < w.replaced)
135+ }
136+ }
137+ clear(s.feedbacks[len(kept):])
138+ s.feedbacks = kept
139+}
140+
141+// sendPresented sends presented for the flip; exact is false when a
142+// later commit of the surface replaced it before the flip (then it is
143+// discarded).
144+func (s *Server) sendPresented(w feedbackWait, o *output, f *ports.FlipInfo, exact bool) {
145+ if !exact {
146+ discard(w.fb)
147+ return
148+ }
149+ if o != nil {
150+ for _, r := range o.resources {
151+ if r.Resource.Alive() && r.Client() == w.fb.Client() {
152+ w.fb.SendSyncOutput(r)
153+ }
154+ }
155+ }
156+ flags := uint32(0)
157+ if !f.Async {
158+ flags |= uint32(presentationtime.WpPresentationFeedbackKindVsync)
159+ }
160+ if f.HardwareClock {
161+ flags |= uint32(presentationtime.WpPresentationFeedbackKindHwClock | presentationtime.WpPresentationFeedbackKindHwCompletion)
162+ }
163+ if f.ZeroCopy {
164+ flags |= uint32(presentationtime.WpPresentationFeedbackKindZeroCopy)
165+ }
166+ sec, nsec := uint64(f.When/time.Second), uint32(f.When%time.Second)
167+ w.fb.SendPresented(uint32(sec>>32), uint32(sec), nsec, uint32(f.Refresh), uint32(f.Seq>>32), uint32(f.Seq), flags)
168+ w.fb.Destroy()
169+}
170+
171+// feedbackTimeout discards a feedback no flip answered: its window is
172+// hidden, on an output that does not flip (headless, switched away), or
173+// was never drawn.
174+const feedbackTimeout = time.Second
175+
176+// dropFeedbacks discards the feedbacks of destroyed surfaces, of outputs
177+// that are gone, and those older than feedbackTimeout.
178+func (s *Server) dropFeedbacks(now time.Time) {
179+ kept := s.feedbacks[:0]
180+ for _, w := range s.feedbacks {
181+ if !w.fb.Resource.Alive() {
182+ continue
183+ }
184+ if w.surf.destroyed || s.outputByNameExact(w.output) == nil || now.Sub(w.at) > feedbackTimeout {
185+ discard(w.fb)
186+ continue
187+ }
188+ kept = append(kept, w)
189+ }
190+ clear(s.feedbacks[len(kept):])
191+ s.feedbacks = kept
192+}
A internal/adapters/wayland/presentation_test.go +273 -0
@@ -0,0 +1,273 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefertty/internal/logging"
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// presentedEvent is one feedback answer: presented (with its fields) or
18+// discarded.
19+type presentedEvent struct {
20+ discarded bool
21+ when time.Duration
22+ refresh uint32
23+ seq uint64
24+ flags uint32
25+ syncOutputs int
26+}
27+
28+type feedbackEvents struct {
29+ wlturbo.BaseProxy
30+ out chan presentedEvent
31+ sync int
32+}
33+
34+func (p *feedbackEvents) Dispatch(e *wlturbo.Event) {
35+ switch uint32(e.Opcode) {
36+ case presentationtime.WpPresentationFeedbackEventSyncOutput:
37+ p.sync++
38+ case presentationtime.WpPresentationFeedbackEventPresented:
39+ d := e.Data()
40+ u := func(i int) uint32 { return binary.NativeEndian.Uint32(d[i*4:]) }
41+ sec := uint64(u(0))<<32 | uint64(u(1))
42+ p.out <- presentedEvent{when: time.Duration(sec)*time.Second + time.Duration(u(2)), refresh: u(3), seq: uint64(u(4))<<32 | uint64(u(5)), flags: u(6), syncOutputs: p.sync}
43+ case presentationtime.WpPresentationFeedbackEventDiscarded:
44+ p.out <- presentedEvent{discarded: true}
45+ }
46+}
47+
48+// presentServer is a server whose flips the test sends.
49+func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, chan ports.OutputPresented, string) {
50+ t.Helper()
51+ dir := t.TempDir()
52+ events := make(chan ports.ClientEvent, 16)
53+ commands := make(chan ports.ClientCommand, 16)
54+ contents := make(chan ports.SurfaceContent, 64)
55+ presented := make(chan ports.OutputPresented, 16)
56+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents, Presented: presented}, logging.For(context.Background(), "wayland"))
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ ctx, cancel := context.WithCancel(context.Background())
61+ done := make(chan error, 1)
62+ go func() { done <- s.Run(ctx) }()
63+ t.Cleanup(func() { cancel(); <-done })
64+ return s, events, commands, contents, presented, dir
65+}
66+
67+func TestPresentationFeedback(t *testing.T) {
68+ s, events, commands, contents, presented, dir := presentServer(t)
69+ c := protocolClient(t, s, dir)
70+ w, surf, xdg := surfaceMapper(t, c, events)()
71+ registerProtocol(t, c, xdg)
72+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"}
73+ for deadline := time.Now().Add(2 * time.Second); ; {
74+ var out string
75+ s.display.Do(func() { out = s.frameOutput(s.windows[w.ID].xdg.surface) })
76+ if out != "" {
77+ break
78+ }
79+ if time.Now().After(deadline) {
80+ t.Fatal("configure not applied")
81+ }
82+ time.Sleep(2 * time.Millisecond)
83+ }
84+ registerProtocol(t, c, bindProtocol(t, c, "wl_output"))
85+ pres := bindProtocol(t, c, "wp_presentation")
86+ registerProtocol(t, c, pres)
87+ // Drain contents so seqs are known.
88+ lastSeq := func() uint64 {
89+ t.Helper()
90+ var seq uint64
91+ deadline := time.After(2 * time.Second)
92+ for {
93+ select {
94+ case got := <-contents:
95+ if got.ID == w.ID {
96+ seq = got.Seq
97+ }
98+ case <-time.After(50 * time.Millisecond):
99+ if seq != 0 {
100+ return seq
101+ }
102+ case <-deadline:
103+ t.Fatal("no content")
104+ }
105+ }
106+ }
107+ feedback := func() *feedbackEvents {
108+ t.Helper()
109+ fb := c.AllocateID()
110+ p := &feedbackEvents{out: make(chan presentedEvent, 4)}
111+ p.SetID(fb)
112+ c.Context().Register(p)
113+ requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
114+ return p
115+ }
116+ answer := func(p *feedbackEvents, what string) presentedEvent {
117+ t.Helper()
118+ deadline := time.Now().Add(2 * time.Second)
119+ for time.Now().Before(deadline) {
120+ if err := c.Roundtrip(); err != nil {
121+ t.Fatal(err)
122+ }
123+ select {
124+ case ev := <-p.out:
125+ return ev
126+ case <-time.After(5 * time.Millisecond):
127+ }
128+ }
129+ t.Fatalf("no answer: %s", what)
130+ return presentedEvent{}
131+ }
132+ // A fresh 1×1 buffer per commit: every commit is a new content.
133+ shm := bindProtocol(t, c, "wl_shm")
134+ registerProtocol(t, c, shm)
135+ fd, err := unix.MemfdCreate("present", 0)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ defer unix.Close(fd)
140+ if err := unix.Ftruncate(fd, 4); err != nil {
141+ t.Fatal(err)
142+ }
143+ pool := c.AllocateID()
144+ registerProtocol(t, c, pool)
145+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
146+ t.Fatal(err)
147+ }
148+ commit := func() {
149+ buf := c.AllocateID()
150+ registerProtocol(t, c, buf)
151+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
152+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
153+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
154+ if err := c.Roundtrip(); err != nil {
155+ t.Fatal(err)
156+ }
157+ }
158+ flip := func(when time.Duration, seq uint64, shows uint64, zero bool) {
159+ presented <- ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}}
160+ }
161+ // Presented with the flip's time, counter and flags.
162+ a := feedback()
163+ commit()
164+ seqA := lastSeq()
165+ flip(5*time.Second+7, 42, seqA, true)
166+ ev := answer(a, "presented")
167+ if ev.discarded || ev.when != 5*time.Second+7 || ev.seq != 42 || ev.refresh != uint32(time.Second/60) || ev.syncOutputs != 1 {
168+ t.Fatalf("presented %+v", ev)
169+ }
170+ want := uint32(presentationtime.WpPresentationFeedbackKindVsync | presentationtime.WpPresentationFeedbackKindHwClock | presentationtime.WpPresentationFeedbackKindHwCompletion | presentationtime.WpPresentationFeedbackKindZeroCopy)
171+ if ev.flags != want {
172+ t.Fatalf("flags %#x want %#x", ev.flags, want)
173+ }
174+ // Two flips in one drain: two presented events, each with its time.
175+ b := feedback()
176+ commit()
177+ seqB := lastSeq()
178+ d := feedback()
179+ commit()
180+ seqD := lastSeq()
181+ flip(6*time.Second, 43, seqB, false)
182+ flip(7*time.Second, 44, seqD, false)
183+ if ev := answer(b, "first of two"); ev.when != 6*time.Second || ev.seq != 43 {
184+ t.Fatalf("first %+v", ev)
185+ }
186+ if ev := answer(d, "second of two"); ev.when != 7*time.Second || ev.seq != 44 {
187+ t.Fatalf("second %+v", ev)
188+ }
189+ // Replaced before shown: discarded when the flip shows the newer one.
190+ old := feedback()
191+ commit()
192+ lastSeq()
193+ commit()
194+ seqNew := lastSeq()
195+ flip(8*time.Second, 45, seqNew, false)
196+ if ev := answer(old, "replaced"); !ev.discarded {
197+ t.Fatalf("replaced: %+v", ev)
198+ }
199+ // A subsurface's feedback follows its root: presented with the root's
200+ // content that the flip shows.
201+ comp := bindProtocol(t, c, "wl_compositor")
202+ sub := bindProtocol(t, c, "wl_subcompositor")
203+ child, subsurf := c.AllocateID(), c.AllocateID()
204+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, child)
205+ registerProtocol(t, c, child)
206+ requestProtocol(t, c, sub, wayland.SubcompositorRequestGetSubsurface, subsurf, child, surf)
207+ registerProtocol(t, c, subsurf)
208+ cfb := c.AllocateID()
209+ cp := &feedbackEvents{out: make(chan presentedEvent, 1)}
210+ cp.SetID(cfb)
211+ c.Context().Register(cp)
212+ requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, cfb)
213+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
214+ commit() // the parent applies the subsurface
215+ seqC := lastSeq()
216+ flip(8500*time.Millisecond, 50, seqC, false)
217+ if ev := answer(cp, "subsurface"); ev.discarded || ev.seq != 50 {
218+ t.Fatalf("subsurface %+v", ev)
219+ }
220+ // Surface destroyed: discarded.
221+ gone := feedback()
222+ commit()
223+ lastSeq()
224+ requestProtocol(t, c, surf, wayland.SurfaceRequestDestroy)
225+ flip(9*time.Second, 46, 0, false)
226+ if ev := answer(gone, "destroyed"); !ev.discarded {
227+ t.Fatalf("destroyed: %+v", ev)
228+ }
229+}
230+
231+// A hidden window (no output) gets discarded at once.
232+func TestPresentationHiddenDiscarded(t *testing.T) {
233+ s, events, _, _, _, dir := presentServer(t)
234+ c := protocolClient(t, s, dir)
235+ _, surf, _ := surfaceMapper(t, c, events)()
236+ pres := bindProtocol(t, c, "wp_presentation")
237+ registerProtocol(t, c, pres)
238+ fb := c.AllocateID()
239+ p := &feedbackEvents{out: make(chan presentedEvent, 1)}
240+ p.SetID(fb)
241+ c.Context().Register(p)
242+ requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
243+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
244+ if err := c.Roundtrip(); err != nil {
245+ t.Fatal(err)
246+ }
247+ if err := c.Roundtrip(); err != nil {
248+ t.Fatal(err)
249+ }
250+ select {
251+ case ev := <-p.out:
252+ if !ev.discarded {
253+ t.Fatalf("%+v", ev)
254+ }
255+ default:
256+ t.Fatal("not discarded")
257+ }
258+}
259+
260+// Commit timing: a commit targeted two refreshes ahead of the last flip
261+// is not applied on the next one.
262+func TestNextRefreshFollowsFlips(t *testing.T) {
263+ s := &Server{flips: map[string]ports.FlipInfo{}}
264+ s.outputs = []*output{{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "A", RefreshMilli: 60000}}}}
265+ now := time.Now()
266+ mono := monotonic(now)
267+ period := time.Second / 60
268+ s.flips["A"] = ports.FlipInfo{When: mono - period/4, Refresh: period}
269+ next := s.nextRefresh("A", now)
270+ if d := next.Sub(now); d < period*3/4-time.Millisecond || d > period*3/4+time.Millisecond {
271+ t.Fatalf("next refresh in %v, want %v", d, period*3/4)
272+ }
273+}
@@ -0,0 +1,273 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefertty/internal/logging"
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// presentedEvent is one feedback answer: presented (with its fields) or
18+// discarded.
19+type presentedEvent struct {
20+ discarded bool
21+ when time.Duration
22+ refresh uint32
23+ seq uint64
24+ flags uint32
25+ syncOutputs int
26+}
27+
28+type feedbackEvents struct {
29+ wlturbo.BaseProxy
30+ out chan presentedEvent
31+ sync int
32+}
33+
34+func (p *feedbackEvents) Dispatch(e *wlturbo.Event) {
35+ switch uint32(e.Opcode) {
36+ case presentationtime.WpPresentationFeedbackEventSyncOutput:
37+ p.sync++
38+ case presentationtime.WpPresentationFeedbackEventPresented:
39+ d := e.Data()
40+ u := func(i int) uint32 { return binary.NativeEndian.Uint32(d[i*4:]) }
41+ sec := uint64(u(0))<<32 | uint64(u(1))
42+ p.out <- presentedEvent{when: time.Duration(sec)*time.Second + time.Duration(u(2)), refresh: u(3), seq: uint64(u(4))<<32 | uint64(u(5)), flags: u(6), syncOutputs: p.sync}
43+ case presentationtime.WpPresentationFeedbackEventDiscarded:
44+ p.out <- presentedEvent{discarded: true}
45+ }
46+}
47+
48+// presentServer is a server whose flips the test sends.
49+func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, chan ports.OutputPresented, string) {
50+ t.Helper()
51+ dir := t.TempDir()
52+ events := make(chan ports.ClientEvent, 16)
53+ commands := make(chan ports.ClientCommand, 16)
54+ contents := make(chan ports.SurfaceContent, 64)
55+ presented := make(chan ports.OutputPresented, 16)
56+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents, Presented: presented}, logging.For(context.Background(), "wayland"))
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ ctx, cancel := context.WithCancel(context.Background())
61+ done := make(chan error, 1)
62+ go func() { done <- s.Run(ctx) }()
63+ t.Cleanup(func() { cancel(); <-done })
64+ return s, events, commands, contents, presented, dir
65+}
66+
67+func TestPresentationFeedback(t *testing.T) {
68+ s, events, commands, contents, presented, dir := presentServer(t)
69+ c := protocolClient(t, s, dir)
70+ w, surf, xdg := surfaceMapper(t, c, events)()
71+ registerProtocol(t, c, xdg)
72+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"}
73+ for deadline := time.Now().Add(2 * time.Second); ; {
74+ var out string
75+ s.display.Do(func() { out = s.frameOutput(s.windows[w.ID].xdg.surface) })
76+ if out != "" {
77+ break
78+ }
79+ if time.Now().After(deadline) {
80+ t.Fatal("configure not applied")
81+ }
82+ time.Sleep(2 * time.Millisecond)
83+ }
84+ registerProtocol(t, c, bindProtocol(t, c, "wl_output"))
85+ pres := bindProtocol(t, c, "wp_presentation")
86+ registerProtocol(t, c, pres)
87+ // Drain contents so seqs are known.
88+ lastSeq := func() uint64 {
89+ t.Helper()
90+ var seq uint64
91+ deadline := time.After(2 * time.Second)
92+ for {
93+ select {
94+ case got := <-contents:
95+ if got.ID == w.ID {
96+ seq = got.Seq
97+ }
98+ case <-time.After(50 * time.Millisecond):
99+ if seq != 0 {
100+ return seq
101+ }
102+ case <-deadline:
103+ t.Fatal("no content")
104+ }
105+ }
106+ }
107+ feedback := func() *feedbackEvents {
108+ t.Helper()
109+ fb := c.AllocateID()
110+ p := &feedbackEvents{out: make(chan presentedEvent, 4)}
111+ p.SetID(fb)
112+ c.Context().Register(p)
113+ requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
114+ return p
115+ }
116+ answer := func(p *feedbackEvents, what string) presentedEvent {
117+ t.Helper()
118+ deadline := time.Now().Add(2 * time.Second)
119+ for time.Now().Before(deadline) {
120+ if err := c.Roundtrip(); err != nil {
121+ t.Fatal(err)
122+ }
123+ select {
124+ case ev := <-p.out:
125+ return ev
126+ case <-time.After(5 * time.Millisecond):
127+ }
128+ }
129+ t.Fatalf("no answer: %s", what)
130+ return presentedEvent{}
131+ }
132+ // A fresh 1×1 buffer per commit: every commit is a new content.
133+ shm := bindProtocol(t, c, "wl_shm")
134+ registerProtocol(t, c, shm)
135+ fd, err := unix.MemfdCreate("present", 0)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ defer unix.Close(fd)
140+ if err := unix.Ftruncate(fd, 4); err != nil {
141+ t.Fatal(err)
142+ }
143+ pool := c.AllocateID()
144+ registerProtocol(t, c, pool)
145+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
146+ t.Fatal(err)
147+ }
148+ commit := func() {
149+ buf := c.AllocateID()
150+ registerProtocol(t, c, buf)
151+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
152+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
153+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
154+ if err := c.Roundtrip(); err != nil {
155+ t.Fatal(err)
156+ }
157+ }
158+ flip := func(when time.Duration, seq uint64, shows uint64, zero bool) {
159+ presented <- ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}}
160+ }
161+ // Presented with the flip's time, counter and flags.
162+ a := feedback()
163+ commit()
164+ seqA := lastSeq()
165+ flip(5*time.Second+7, 42, seqA, true)
166+ ev := answer(a, "presented")
167+ if ev.discarded || ev.when != 5*time.Second+7 || ev.seq != 42 || ev.refresh != uint32(time.Second/60) || ev.syncOutputs != 1 {
168+ t.Fatalf("presented %+v", ev)
169+ }
170+ want := uint32(presentationtime.WpPresentationFeedbackKindVsync | presentationtime.WpPresentationFeedbackKindHwClock | presentationtime.WpPresentationFeedbackKindHwCompletion | presentationtime.WpPresentationFeedbackKindZeroCopy)
171+ if ev.flags != want {
172+ t.Fatalf("flags %#x want %#x", ev.flags, want)
173+ }
174+ // Two flips in one drain: two presented events, each with its time.
175+ b := feedback()
176+ commit()
177+ seqB := lastSeq()
178+ d := feedback()
179+ commit()
180+ seqD := lastSeq()
181+ flip(6*time.Second, 43, seqB, false)
182+ flip(7*time.Second, 44, seqD, false)
183+ if ev := answer(b, "first of two"); ev.when != 6*time.Second || ev.seq != 43 {
184+ t.Fatalf("first %+v", ev)
185+ }
186+ if ev := answer(d, "second of two"); ev.when != 7*time.Second || ev.seq != 44 {
187+ t.Fatalf("second %+v", ev)
188+ }
189+ // Replaced before shown: discarded when the flip shows the newer one.
190+ old := feedback()
191+ commit()
192+ lastSeq()
193+ commit()
194+ seqNew := lastSeq()
195+ flip(8*time.Second, 45, seqNew, false)
196+ if ev := answer(old, "replaced"); !ev.discarded {
197+ t.Fatalf("replaced: %+v", ev)
198+ }
199+ // A subsurface's feedback follows its root: presented with the root's
200+ // content that the flip shows.
201+ comp := bindProtocol(t, c, "wl_compositor")
202+ sub := bindProtocol(t, c, "wl_subcompositor")
203+ child, subsurf := c.AllocateID(), c.AllocateID()
204+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, child)
205+ registerProtocol(t, c, child)
206+ requestProtocol(t, c, sub, wayland.SubcompositorRequestGetSubsurface, subsurf, child, surf)
207+ registerProtocol(t, c, subsurf)
208+ cfb := c.AllocateID()
209+ cp := &feedbackEvents{out: make(chan presentedEvent, 1)}
210+ cp.SetID(cfb)
211+ c.Context().Register(cp)
212+ requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, cfb)
213+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
214+ commit() // the parent applies the subsurface
215+ seqC := lastSeq()
216+ flip(8500*time.Millisecond, 50, seqC, false)
217+ if ev := answer(cp, "subsurface"); ev.discarded || ev.seq != 50 {
218+ t.Fatalf("subsurface %+v", ev)
219+ }
220+ // Surface destroyed: discarded.
221+ gone := feedback()
222+ commit()
223+ lastSeq()
224+ requestProtocol(t, c, surf, wayland.SurfaceRequestDestroy)
225+ flip(9*time.Second, 46, 0, false)
226+ if ev := answer(gone, "destroyed"); !ev.discarded {
227+ t.Fatalf("destroyed: %+v", ev)
228+ }
229+}
230+
231+// A hidden window (no output) gets discarded at once.
232+func TestPresentationHiddenDiscarded(t *testing.T) {
233+ s, events, _, _, _, dir := presentServer(t)
234+ c := protocolClient(t, s, dir)
235+ _, surf, _ := surfaceMapper(t, c, events)()
236+ pres := bindProtocol(t, c, "wp_presentation")
237+ registerProtocol(t, c, pres)
238+ fb := c.AllocateID()
239+ p := &feedbackEvents{out: make(chan presentedEvent, 1)}
240+ p.SetID(fb)
241+ c.Context().Register(p)
242+ requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
243+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
244+ if err := c.Roundtrip(); err != nil {
245+ t.Fatal(err)
246+ }
247+ if err := c.Roundtrip(); err != nil {
248+ t.Fatal(err)
249+ }
250+ select {
251+ case ev := <-p.out:
252+ if !ev.discarded {
253+ t.Fatalf("%+v", ev)
254+ }
255+ default:
256+ t.Fatal("not discarded")
257+ }
258+}
259+
260+// Commit timing: a commit targeted two refreshes ahead of the last flip
261+// is not applied on the next one.
262+func TestNextRefreshFollowsFlips(t *testing.T) {
263+ s := &Server{flips: map[string]ports.FlipInfo{}}
264+ s.outputs = []*output{{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "A", RefreshMilli: 60000}}}}
265+ now := time.Now()
266+ mono := monotonic(now)
267+ period := time.Second / 60
268+ s.flips["A"] = ports.FlipInfo{When: mono - period/4, Refresh: period}
269+ next := s.nextRefresh("A", now)
270+ if d := next.Sub(now); d < period*3/4-time.Millisecond || d > period*3/4+time.Millisecond {
271+ t.Fatalf("next refresh in %v, want %v", d, period*3/4)
272+ }
273+}
M internal/adapters/wayland/server.go +6 -1
@@ -109,7 +109,11 @@ type Server struct {
109109 contents map[ports.WindowID]ports.SurfaceContent
110110 contentSeq map[ports.WindowID]uint64
111111 // damage is each window's recent damage (contentMu).
112- damage map[ports.WindowID][]ports.SeqDamage
112+ damage map[ports.WindowID][]ports.SeqDamage
113+ // feedbacks wait for the flip that shows their content (presentation.go).
114+ feedbacks []feedbackWait
115+ // flips is each output's latest flip (pacer, under Do).
116+ flips map[string]ports.FlipInfo
113117 contentNotify chan struct{}
114118 contentReady chan struct{}
115119 outputs []*output
@@ -168,6 +172,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
168172 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
169173 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
170174 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
175+ s.flips = map[string]ports.FlipInfo{}
171176 s.tokens = map[string]activationToken{}
172177 if opts.Keymap != "" {
173178 fd, size, e := keymapFile(opts.Keymap)
@@ -109,7 +109,11 @@ type Server struct {
109 contents map[ports.WindowID]ports.SurfaceContent109 contents map[ports.WindowID]ports.SurfaceContent
110 contentSeq map[ports.WindowID]uint64110 contentSeq map[ports.WindowID]uint64
111 // damage is each window's recent damage (contentMu).111 // damage is each window's recent damage (contentMu).
112- damage map[ports.WindowID][]ports.SeqDamage112+ damage map[ports.WindowID][]ports.SeqDamage
113+ // feedbacks wait for the flip that shows their content (presentation.go).
114+ feedbacks []feedbackWait
115+ // flips is each output's latest flip (pacer, under Do).
116+ flips map[string]ports.FlipInfo
113 contentNotify chan struct{}117 contentNotify chan struct{}
114 contentReady chan struct{}118 contentReady chan struct{}
115 outputs []*output119 outputs []*output
@@ -168,6 +172,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
168 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}172 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
169 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}173 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
170 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}174 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
175+ s.flips = map[string]ports.FlipInfo{}
171 s.tokens = map[string]activationToken{}176 s.tokens = map[string]activationToken{}
172 if opts.Keymap != "" {177 if opts.Keymap != "" {
173 fd, size, e := keymapFile(opts.Keymap)178 fd, size, e := keymapFile(opts.Keymap)
M internal/adapters/wayland/surface.go +10 -0
@@ -1,6 +1,7 @@
11 package wayland
22
33 import (
4+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
45 "image"
56 "time"
67
@@ -66,6 +67,8 @@ type surface struct {
6667 // pixels (full: everything), read by the window's damage history.
6768 pendingDamage, pendingBufDamage []ports.Rect
6869 committed damage
70+ // pendingFeedback are wp_presentation feedbacks for the next commit.
71+ pendingFeedback []*presentationtime.WpPresentationFeedback
6972 }
7073
7174 // damage is what one commit changed in a surface's buffer.
@@ -174,6 +177,10 @@ func (s *surface) detach() {
174177
175178 func (s *surface) Destroy(*wayland.Surface) {
176179 s.destroyed = true
180+ for _, fb := range s.pendingFeedback {
181+ discard(fb)
182+ }
183+ s.pendingFeedback = nil
177184 s.tearing = nil // the control becomes inert
178185 s.dropQueue()
179186 if s.server.cursorSurface == s {
@@ -309,6 +316,9 @@ func (s *surface) applyCommit() {
309316 }
310317 s.redraw()
311318 }
319+ fb := s.pendingFeedback
320+ s.pendingFeedback = nil
321+ s.commitFeedback(fb)
312322 }
313323 func (s *surface) Damage(_ *wayland.Surface, x, y, w, h int32) {
314324 if w > 0 && h > 0 && len(s.pendingDamage) <= maxDamageRects {
@@ -1,6 +1,7 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "github.com/bnema/purego-libwayland/protocol/presentationtime"
4 "image"5 "image"
5 "time"6 "time"
6 7
@@ -66,6 +67,8 @@ type surface struct {
66 // pixels (full: everything), read by the window's damage history.67 // pixels (full: everything), read by the window's damage history.
67 pendingDamage, pendingBufDamage []ports.Rect68 pendingDamage, pendingBufDamage []ports.Rect
68 committed damage69 committed damage
70+ // pendingFeedback are wp_presentation feedbacks for the next commit.
71+ pendingFeedback []*presentationtime.WpPresentationFeedback
69 }72 }
70 73
71 // damage is what one commit changed in a surface's buffer.74 // damage is what one commit changed in a surface's buffer.
@@ -174,6 +177,10 @@ func (s *surface) detach() {
174 177
175 func (s *surface) Destroy(*wayland.Surface) {178 func (s *surface) Destroy(*wayland.Surface) {
176 s.destroyed = true179 s.destroyed = true
180+ for _, fb := range s.pendingFeedback {
181+ discard(fb)
182+ }
183+ s.pendingFeedback = nil
177 s.tearing = nil // the control becomes inert184 s.tearing = nil // the control becomes inert
178 s.dropQueue()185 s.dropQueue()
179 if s.server.cursorSurface == s {186 if s.server.cursorSurface == s {
@@ -309,6 +316,9 @@ func (s *surface) applyCommit() {
309 }316 }
310 s.redraw()317 s.redraw()
311 }318 }
319+ fb := s.pendingFeedback
320+ s.pendingFeedback = nil
321+ s.commitFeedback(fb)
312 }322 }
313 func (s *surface) Damage(_ *wayland.Surface, x, y, w, h int32) {323 func (s *surface) Damage(_ *wayland.Surface, x, y, w, h int32) {
314 if w > 0 && h > 0 && len(s.pendingDamage) <= maxDamageRects {324 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 23

Tags 10

↑↓ moveenter openesc close