detached e38f0b1

fix(wayland): idle frame pacer, coalesce flips and throttle hidden surfaces

commit
e38f0b1206d50eef16f2167ce7460aa661853861
author
brice · · 2026-09-25 15:20 UTC
parent
37a29ce
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +161 -59
M internal/adapters/wayland/frames.go +103 -29
@@ -2,90 +2,164 @@ package wayland
22
33 import (
44 "context"
5+ "slices"
56 "time"
7+
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
69 )
710
811 // Frame callbacks (wl_surface.frame) tell a client when to draw its next
912 // frame. They are paced per output: on its page flip when it flips, else
1013 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
11-// second and a V-Sync game follows the screen.
14+// second and a V-Sync game follows the screen. Surfaces with no output
15+// (hidden windows, cursors, unplaced surfaces) are paced at 60 Hz. The
16+// pacer sleeps while no callback waits.
1217
1318 // defaultFramePeriod paces surfaces without an output (60 Hz).
1419 const defaultFramePeriod = time.Second / 60
1520
16-// pace fires the due frame callbacks on each page flip and timer tick.
21+// pace fires the due frame callbacks on page flips and deadlines.
1722 func (s *Server) pace(ctx context.Context) {
1823 timer := time.NewTimer(defaultFramePeriod)
1924 defer timer.Stop()
2025 presented := s.channels.Presented
2126 for {
22- flipped, flip := "", false
27+ flipped := map[string]bool{}
2328 select {
2429 case <-ctx.Done():
2530 return
2631 case <-s.display.Stopped():
2732 return
2833 case p := <-presented:
29- flipped, flip = p.Output, true
34+ flipped[p.Output] = true
35+ case <-s.frameReady:
3036 case <-timer.C:
3137 }
32- wait := defaultFramePeriod
38+ // Coalesce flips queued while the display was busy: one burst of
39+ // callbacks per output, not one per stale flip.
40+ drain:
41+ for {
42+ select {
43+ case p := <-presented:
44+ flipped[p.Output] = true
45+ default:
46+ break drain
47+ }
48+ }
49+ wait, idle := defaultFramePeriod, false
3350 if !s.display.Do(func() {
3451 if ctx.Err() == nil {
35- wait = s.fireFrames(time.Now(), flipped, flip)
52+ var due []string
53+ due, wait, idle = s.dueFrames(time.Now(), flipped)
54+ for _, name := range due {
55+ s.sendFrames(name)
56+ }
3657 }
3758 }) {
3859 return
3960 }
61+ if idle {
62+ // Nothing waits: sleep until a commit queues a callback
63+ // (frameReady) or a flip.
64+ timer.Stop()
65+ continue
66+ }
4067 timer.Reset(wait)
4168 }
4269 }
4370
44-// fireFrames sends the frame callbacks due at now (all those of a flipped
45-// output) and returns the time to the next deadline.
46-func (s *Server) fireFrames(now time.Time, flipped string, flip bool) time.Duration {
47- periods := make(map[string]time.Duration, len(s.outputs))
71+// frameOutput is the output showing a surface, "" when none does (a hidden
72+// window, a cursor): unlike outputOfSurface, no fallback to the focused one.
73+func (s *Server) frameOutput(surf *surface) string {
74+ surf = surf.root()
75+ for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
76+ surf = surf.xdg.window.popup.parent.xdg.surface.root()
77+ }
78+ switch {
79+ case surf.xdg != nil && surf.xdg.window != nil:
80+ return surf.xdg.window.last.Output
81+ case surf.layer != nil && surf.layer.output != nil:
82+ return surf.layer.output.name()
83+ }
84+ return ""
85+}
86+
87+// queueFrames adds a committed surface's callbacks to its output's queue
88+// ("" when it has none) and wakes the pacer.
89+func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {
90+ s.awaiting[name] = append(s.awaiting[name], callbacks...)
91+ select {
92+ case s.frameReady <- struct{}{}:
93+ default:
94+ }
95+}
96+
97+// dueFrames returns the queues to fire now: flipped outputs and those past
98+// their deadline. wait is the time to the next deadline; idle is set when
99+// no callback waits after firing.
100+func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
101+ periods := make(map[string]time.Duration, len(s.outputs)+1)
48102 for _, o := range s.outputs {
49103 periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)
50104 }
51- if flip {
52- if p, ok := periods[flipped]; ok {
53- s.sendFrames(flipped)
54- // A flipping output is paced by its flips; the timer only
55- // catches a missed one.
56- s.frameDue[flipped] = now.Add(p + p/2)
105+ // Callbacks of surfaces whose output is gone go with the outputless ones.
106+ for name, callbacks := range s.awaiting {
107+ if _, ok := periods[name]; !ok && name != "" {
108+ delete(s.awaiting, name)
109+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
57110 }
58111 }
112+ periods[""] = defaultFramePeriod
59113 for name := range s.frameDue {
60114 if _, ok := periods[name]; !ok {
61115 delete(s.frameDue, name)
62116 }
63117 }
64118 for name, p := range periods {
65- if due, ok := s.frameDue[name]; !ok || !now.Before(due) {
66- s.sendFrames(name)
67- s.frameDue[name] = now.Add(p)
119+ due, ok := s.frameDue[name]
120+ switch {
121+ case flipped[name] && name != "":
122+ // A flipping output is paced by its flips; its deadline only
123+ // catches a missed one.
124+ fire = append(fire, name)
125+ s.frameDue[name] = now.Add(p + p/2)
126+ case !ok || !now.Before(due):
127+ if len(s.awaiting[name]) == 0 {
128+ // Idle: the next callback goes out as soon as it comes.
129+ delete(s.frameDue, name)
130+ continue
131+ }
132+ fire = append(fire, name)
133+ // Keep the phase; after a stall, restart from now.
134+ next := due.Add(p)
135+ if !ok || !next.After(now) {
136+ next = now.Add(p)
137+ }
138+ s.frameDue[name] = next
68139 }
69140 }
70- // Callbacks of surfaces without a known output go at the default rate.
71- for name := range s.awaiting {
72- if _, ok := periods[name]; !ok {
73- s.sendFrames(name)
141+ waiting := false
142+ wait = defaultFramePeriod
143+ for name, callbacks := range s.awaiting {
144+ if len(callbacks) == 0 || slices.Contains(fire, name) {
145+ continue
146+ }
147+ waiting = true
148+ if due, ok := s.frameDue[name]; ok {
149+ wait = min(wait, due.Sub(now))
150+ } else {
151+ wait = 0
74152 }
75153 }
76- wait := defaultFramePeriod
77- for _, due := range s.frameDue {
78- wait = min(wait, due.Sub(now))
79- }
80- return max(wait, time.Millisecond)
154+ return fire, max(wait, time.Millisecond), !waiting
81155 }
82156
83157 func (s *Server) sendFrames(name string) {
84158 callbacks := s.awaiting[name]
159+ delete(s.awaiting, name)
85160 if len(callbacks) == 0 {
86161 return
87162 }
88- delete(s.awaiting, name)
89163 ms := uint32(time.Since(s.started).Milliseconds())
90164 for _, cb := range callbacks {
91165 if !cb.Resource.Alive() {
@@ -2,90 +2,164 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "slices"
5 "time"6 "time"
7+
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
6 )9 )
7 10
8 // Frame callbacks (wl_surface.frame) tell a client when to draw its next11 // Frame callbacks (wl_surface.frame) tell a client when to draw its next
9 // frame. They are paced per output: on its page flip when it flips, else12 // frame. They are paced per output: on its page flip when it flips, else
10 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a13 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
11-// second and a V-Sync game follows the screen.14+// second and a V-Sync game follows the screen. Surfaces with no output
15+// (hidden windows, cursors, unplaced surfaces) are paced at 60 Hz. The
16+// pacer sleeps while no callback waits.
12 17
13 // defaultFramePeriod paces surfaces without an output (60 Hz).18 // defaultFramePeriod paces surfaces without an output (60 Hz).
14 const defaultFramePeriod = time.Second / 6019 const defaultFramePeriod = time.Second / 60
15 20
16-// pace fires the due frame callbacks on each page flip and timer tick.21+// pace fires the due frame callbacks on page flips and deadlines.
17 func (s *Server) pace(ctx context.Context) {22 func (s *Server) pace(ctx context.Context) {
18 timer := time.NewTimer(defaultFramePeriod)23 timer := time.NewTimer(defaultFramePeriod)
19 defer timer.Stop()24 defer timer.Stop()
20 presented := s.channels.Presented25 presented := s.channels.Presented
21 for {26 for {
22- flipped, flip := "", false27+ flipped := map[string]bool{}
23 select {28 select {
24 case <-ctx.Done():29 case <-ctx.Done():
25 return30 return
26 case <-s.display.Stopped():31 case <-s.display.Stopped():
27 return32 return
28 case p := <-presented:33 case p := <-presented:
29- flipped, flip = p.Output, true34+ flipped[p.Output] = true
35+ case <-s.frameReady:
30 case <-timer.C:36 case <-timer.C:
31 }37 }
32- wait := defaultFramePeriod38+ // Coalesce flips queued while the display was busy: one burst of
39+ // callbacks per output, not one per stale flip.
40+ drain:
41+ for {
42+ select {
43+ case p := <-presented:
44+ flipped[p.Output] = true
45+ default:
46+ break drain
47+ }
48+ }
49+ wait, idle := defaultFramePeriod, false
33 if !s.display.Do(func() {50 if !s.display.Do(func() {
34 if ctx.Err() == nil {51 if ctx.Err() == nil {
35- wait = s.fireFrames(time.Now(), flipped, flip)52+ var due []string
53+ due, wait, idle = s.dueFrames(time.Now(), flipped)
54+ for _, name := range due {
55+ s.sendFrames(name)
56+ }
36 }57 }
37 }) {58 }) {
38 return59 return
39 }60 }
61+ if idle {
62+ // Nothing waits: sleep until a commit queues a callback
63+ // (frameReady) or a flip.
64+ timer.Stop()
65+ continue
66+ }
40 timer.Reset(wait)67 timer.Reset(wait)
41 }68 }
42 }69 }
43 70
44-// fireFrames sends the frame callbacks due at now (all those of a flipped71+// frameOutput is the output showing a surface, "" when none does (a hidden
45-// output) and returns the time to the next deadline.72+// window, a cursor): unlike outputOfSurface, no fallback to the focused one.
46-func (s *Server) fireFrames(now time.Time, flipped string, flip bool) time.Duration {73+func (s *Server) frameOutput(surf *surface) string {
47- periods := make(map[string]time.Duration, len(s.outputs))74+ surf = surf.root()
75+ for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
76+ surf = surf.xdg.window.popup.parent.xdg.surface.root()
77+ }
78+ switch {
79+ case surf.xdg != nil && surf.xdg.window != nil:
80+ return surf.xdg.window.last.Output
81+ case surf.layer != nil && surf.layer.output != nil:
82+ return surf.layer.output.name()
83+ }
84+ return ""
85+}
86+
87+// queueFrames adds a committed surface's callbacks to its output's queue
88+// ("" when it has none) and wakes the pacer.
89+func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {
90+ s.awaiting[name] = append(s.awaiting[name], callbacks...)
91+ select {
92+ case s.frameReady <- struct{}{}:
93+ default:
94+ }
95+}
96+
97+// dueFrames returns the queues to fire now: flipped outputs and those past
98+// their deadline. wait is the time to the next deadline; idle is set when
99+// no callback waits after firing.
100+func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
101+ periods := make(map[string]time.Duration, len(s.outputs)+1)
48 for _, o := range s.outputs {102 for _, o := range s.outputs {
49 periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)103 periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)
50 }104 }
51- if flip {105+ // Callbacks of surfaces whose output is gone go with the outputless ones.
52- if p, ok := periods[flipped]; ok {106+ for name, callbacks := range s.awaiting {
53- s.sendFrames(flipped)107+ if _, ok := periods[name]; !ok && name != "" {
54- // A flipping output is paced by its flips; the timer only108+ delete(s.awaiting, name)
55- // catches a missed one.109+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
56- s.frameDue[flipped] = now.Add(p + p/2)
57 }110 }
58 }111 }
112+ periods[""] = defaultFramePeriod
59 for name := range s.frameDue {113 for name := range s.frameDue {
60 if _, ok := periods[name]; !ok {114 if _, ok := periods[name]; !ok {
61 delete(s.frameDue, name)115 delete(s.frameDue, name)
62 }116 }
63 }117 }
64 for name, p := range periods {118 for name, p := range periods {
65- if due, ok := s.frameDue[name]; !ok || !now.Before(due) {119+ due, ok := s.frameDue[name]
66- s.sendFrames(name)120+ switch {
67- s.frameDue[name] = now.Add(p)121+ case flipped[name] && name != "":
122+ // A flipping output is paced by its flips; its deadline only
123+ // catches a missed one.
124+ fire = append(fire, name)
125+ s.frameDue[name] = now.Add(p + p/2)
126+ case !ok || !now.Before(due):
127+ if len(s.awaiting[name]) == 0 {
128+ // Idle: the next callback goes out as soon as it comes.
129+ delete(s.frameDue, name)
130+ continue
131+ }
132+ fire = append(fire, name)
133+ // Keep the phase; after a stall, restart from now.
134+ next := due.Add(p)
135+ if !ok || !next.After(now) {
136+ next = now.Add(p)
137+ }
138+ s.frameDue[name] = next
68 }139 }
69 }140 }
70- // Callbacks of surfaces without a known output go at the default rate.141+ waiting := false
71- for name := range s.awaiting {142+ wait = defaultFramePeriod
72- if _, ok := periods[name]; !ok {143+ for name, callbacks := range s.awaiting {
73- s.sendFrames(name)144+ if len(callbacks) == 0 || slices.Contains(fire, name) {
145+ continue
146+ }
147+ waiting = true
148+ if due, ok := s.frameDue[name]; ok {
149+ wait = min(wait, due.Sub(now))
150+ } else {
151+ wait = 0
74 }152 }
75 }153 }
76- wait := defaultFramePeriod154+ return fire, max(wait, time.Millisecond), !waiting
77- for _, due := range s.frameDue {
78- wait = min(wait, due.Sub(now))
79- }
80- return max(wait, time.Millisecond)
81 }155 }
82 156
83 func (s *Server) sendFrames(name string) {157 func (s *Server) sendFrames(name string) {
84 callbacks := s.awaiting[name]158 callbacks := s.awaiting[name]
159+ delete(s.awaiting, name)
85 if len(callbacks) == 0 {160 if len(callbacks) == 0 {
86 return161 return
87 }162 }
88- delete(s.awaiting, name)
89 ms := uint32(time.Since(s.started).Milliseconds())163 ms := uint32(time.Since(s.started).Milliseconds())
90 for _, cb := range callbacks {164 for _, cb := range callbacks {
91 if !cb.Resource.Alive() {165 if !cb.Resource.Alive() {
M internal/adapters/wayland/frames_test.go +47 -16
@@ -1,10 +1,12 @@
11 package wayland
22
33 import (
4+ "slices"
45 "testing"
56 "time"
67
78 "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
810 )
911
1012 func TestFramePeriod(t *testing.T) {
@@ -18,28 +20,57 @@ func TestFramePeriod(t *testing.T) {
1820 }
1921 }
2022
21-// Each output is paced at its own refresh; a page flip resets its deadline.
22-func TestFireFramesPacing(t *testing.T) {
23+// Each output is paced at its own refresh; a flip fires its queue and
24+// pushes its deadline back; the pacer idles with nothing queued.
25+func TestDueFrames(t *testing.T) {
2326 fast := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 165000}}}
2427 slow := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "HDMI-A-1", RefreshMilli: 60000}}}
25- s := &Server{outputs: []*output{fast, slow}, frameDue: map[string]time.Time{}}
28+ s := &Server{outputs: []*output{fast, slow}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
29+ // dueFrames only counts the queued callbacks: nil ones stand for them.
30+ queue := func(name string) { s.awaiting[name] = append(s.awaiting[name], nil) }
31+ fired := func(fire []string) {
32+ for _, name := range fire {
33+ delete(s.awaiting, name)
34+ }
35+ }
2636 now := time.Unix(100, 0)
27- if wait := s.fireFrames(now, "", false); wait != framePeriod(165000) {
28- t.Fatalf("first wait %v", wait)
37+ if _, _, idle := s.dueFrames(now, nil); !idle {
38+ t.Fatal("not idle without callbacks")
39+ }
40+ // A first callback goes out at once, then one per refresh.
41+ queue("DP-2")
42+ queue("HDMI-A-1")
43+ fire, _, idle := s.dueFrames(now, nil)
44+ if len(fire) != 2 || !idle {
45+ t.Fatalf("first fire %v idle %v", fire, idle)
46+ }
47+ fired(fire)
48+ queue("DP-2")
49+ queue("HDMI-A-1")
50+ fire, wait, _ := s.dueFrames(now.Add(time.Millisecond), nil)
51+ if len(fire) != 0 || wait != framePeriod(165000)-time.Millisecond {
52+ t.Fatalf("early fire %v wait %v", fire, wait)
2953 }
30- if due := s.frameDue["HDMI-A-1"]; due != now.Add(time.Second/60) {
31- t.Fatalf("60 Hz due %v", due.Sub(now))
54+ at := now.Add(framePeriod(165000))
55+ fire, wait, _ = s.dueFrames(at, nil)
56+ if !slices.Equal(fire, []string{"DP-2"}) || wait != time.Second/60-framePeriod(165000) {
57+ t.Fatalf("165 Hz fire %v wait %v", fire, wait)
3258 }
33- // A flip on DP-2 pushes its timer back: flips pace it.
34- later := now.Add(time.Millisecond)
35- s.fireFrames(later, "DP-2", true)
36- if due := s.frameDue["DP-2"]; due != later.Add(framePeriod(165000)*3/2) {
37- t.Fatalf("flip due %v", due.Sub(later))
59+ fired(fire)
60+ // A flip fires its output early and pushes its deadline to 1.5 periods.
61+ queue("DP-2")
62+ flip := at.Add(time.Millisecond)
63+ fire, _, _ = s.dueFrames(flip, map[string]bool{"DP-2": true})
64+ if !slices.Equal(fire, []string{"DP-2"}) || s.frameDue["DP-2"] != flip.Add(framePeriod(165000)*3/2) {
65+ t.Fatalf("flip fire %v due %v", fire, s.frameDue["DP-2"].Sub(flip))
3866 }
39- // An unplugged output is forgotten.
67+ fired(fire)
68+ // A queue on an unplugged output moves to the outputless one (60 Hz).
4069 s.outputs = []*output{fast}
41- s.fireFrames(later, "", false)
42- if _, ok := s.frameDue["HDMI-A-1"]; ok {
43- t.Fatal("stale output kept")
70+ queue("HDMI-A-1")
71+ fire, _, _ = s.dueFrames(flip, nil)
72+ fired(fire)
73+ if _, ok := s.frameDue["HDMI-A-1"]; ok || len(s.awaiting["HDMI-A-1"]) != 0 || !slices.Equal(fire, []string{""}) {
74+ t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)
4475 }
4576 }
@@ -1,10 +1,12 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "slices"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
7 "github.com/bnema/nefertty/internal/ports"8 "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
8 )10 )
9 11
10 func TestFramePeriod(t *testing.T) {12 func TestFramePeriod(t *testing.T) {
@@ -18,28 +20,57 @@ func TestFramePeriod(t *testing.T) {
18 }20 }
19 }21 }
20 22
21-// Each output is paced at its own refresh; a page flip resets its deadline.23+// Each output is paced at its own refresh; a flip fires its queue and
22-func TestFireFramesPacing(t *testing.T) {24+// pushes its deadline back; the pacer idles with nothing queued.
25+func TestDueFrames(t *testing.T) {
23 fast := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 165000}}}26 fast := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 165000}}}
24 slow := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "HDMI-A-1", RefreshMilli: 60000}}}27 slow := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "HDMI-A-1", RefreshMilli: 60000}}}
25- s := &Server{outputs: []*output{fast, slow}, frameDue: map[string]time.Time{}}28+ s := &Server{outputs: []*output{fast, slow}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
29+ // dueFrames only counts the queued callbacks: nil ones stand for them.
30+ queue := func(name string) { s.awaiting[name] = append(s.awaiting[name], nil) }
31+ fired := func(fire []string) {
32+ for _, name := range fire {
33+ delete(s.awaiting, name)
34+ }
35+ }
26 now := time.Unix(100, 0)36 now := time.Unix(100, 0)
27- if wait := s.fireFrames(now, "", false); wait != framePeriod(165000) {37+ if _, _, idle := s.dueFrames(now, nil); !idle {
28- t.Fatalf("first wait %v", wait)38+ t.Fatal("not idle without callbacks")
39+ }
40+ // A first callback goes out at once, then one per refresh.
41+ queue("DP-2")
42+ queue("HDMI-A-1")
43+ fire, _, idle := s.dueFrames(now, nil)
44+ if len(fire) != 2 || !idle {
45+ t.Fatalf("first fire %v idle %v", fire, idle)
46+ }
47+ fired(fire)
48+ queue("DP-2")
49+ queue("HDMI-A-1")
50+ fire, wait, _ := s.dueFrames(now.Add(time.Millisecond), nil)
51+ if len(fire) != 0 || wait != framePeriod(165000)-time.Millisecond {
52+ t.Fatalf("early fire %v wait %v", fire, wait)
29 }53 }
30- if due := s.frameDue["HDMI-A-1"]; due != now.Add(time.Second/60) {54+ at := now.Add(framePeriod(165000))
31- t.Fatalf("60 Hz due %v", due.Sub(now))55+ fire, wait, _ = s.dueFrames(at, nil)
56+ if !slices.Equal(fire, []string{"DP-2"}) || wait != time.Second/60-framePeriod(165000) {
57+ t.Fatalf("165 Hz fire %v wait %v", fire, wait)
32 }58 }
33- // A flip on DP-2 pushes its timer back: flips pace it.59+ fired(fire)
34- later := now.Add(time.Millisecond)60+ // A flip fires its output early and pushes its deadline to 1.5 periods.
35- s.fireFrames(later, "DP-2", true)61+ queue("DP-2")
36- if due := s.frameDue["DP-2"]; due != later.Add(framePeriod(165000)*3/2) {62+ flip := at.Add(time.Millisecond)
37- t.Fatalf("flip due %v", due.Sub(later))63+ fire, _, _ = s.dueFrames(flip, map[string]bool{"DP-2": true})
64+ if !slices.Equal(fire, []string{"DP-2"}) || s.frameDue["DP-2"] != flip.Add(framePeriod(165000)*3/2) {
65+ t.Fatalf("flip fire %v due %v", fire, s.frameDue["DP-2"].Sub(flip))
38 }66 }
39- // An unplugged output is forgotten.67+ fired(fire)
68+ // A queue on an unplugged output moves to the outputless one (60 Hz).
40 s.outputs = []*output{fast}69 s.outputs = []*output{fast}
41- s.fireFrames(later, "", false)70+ queue("HDMI-A-1")
42- if _, ok := s.frameDue["HDMI-A-1"]; ok {71+ fire, _, _ = s.dueFrames(flip, nil)
43- t.Fatal("stale output kept")72+ fired(fire)
73+ if _, ok := s.frameDue["HDMI-A-1"]; ok || len(s.awaiting["HDMI-A-1"]) != 0 || !slices.Equal(fire, []string{""}) {
74+ t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)
44 }75 }
45 }76 }
M internal/adapters/wayland/server.go +10 -9
@@ -49,14 +49,15 @@ type Server struct {
4949 channels Channels
5050 // awaiting holds frame callbacks by output name, due at its next
5151 // frame (frameDue, or its page flip).
52- awaiting map[string][]*wayland.Callback
53- frameDue map[string]time.Time
54- frames uint64
55- started time.Time
56- surfaces map[*server.Resource]*surface
57- buffers map[*server.Resource]clientBuffer
58- dmabuf *dmabufGlobal
59- serial uint32
52+ awaiting map[string][]*wayland.Callback
53+ frameDue map[string]time.Time
54+ frameReady chan struct{}
55+ frames uint64
56+ started time.Time
57+ surfaces map[*server.Resource]*surface
58+ buffers map[*server.Resource]clientBuffer
59+ dmabuf *dmabufGlobal
60+ serial uint32
6061 // press is the serial of the last button or key press, sent to
6162 // pressClient: popup grabs must come from it.
6263 press uint32
@@ -142,7 +143,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
142143 d.Close()
143144 return nil, err
144145 }
145- s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, 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), 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}
146+ s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), 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), 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}
146147 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
147148 if opts.Keymap != "" {
148149 fd, size, e := keymapFile(opts.Keymap)
@@ -49,14 +49,15 @@ type Server struct {
49 channels Channels49 channels Channels
50 // awaiting holds frame callbacks by output name, due at its next50 // awaiting holds frame callbacks by output name, due at its next
51 // frame (frameDue, or its page flip).51 // frame (frameDue, or its page flip).
52- awaiting map[string][]*wayland.Callback52+ awaiting map[string][]*wayland.Callback
53- frameDue map[string]time.Time53+ frameDue map[string]time.Time
54- frames uint6454+ frameReady chan struct{}
55- started time.Time55+ frames uint64
56- surfaces map[*server.Resource]*surface56+ started time.Time
57- buffers map[*server.Resource]clientBuffer57+ surfaces map[*server.Resource]*surface
58- dmabuf *dmabufGlobal58+ buffers map[*server.Resource]clientBuffer
59- serial uint3259+ dmabuf *dmabufGlobal
60+ serial uint32
60 // press is the serial of the last button or key press, sent to61 // press is the serial of the last button or key press, sent to
61 // pressClient: popup grabs must come from it.62 // pressClient: popup grabs must come from it.
62 press uint3263 press uint32
@@ -142,7 +143,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
142 d.Close()143 d.Close()
143 return nil, err144 return nil, err
144 }145 }
145- s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, 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), 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}146+ s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), 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), 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}
146 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}147 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
147 if opts.Keymap != "" {148 if opts.Keymap != "" {
148 fd, size, e := keymapFile(opts.Keymap)149 fd, size, e := keymapFile(opts.Keymap)
M internal/adapters/wayland/surface.go +1 -5
@@ -198,11 +198,7 @@ func (s *surface) Commit(*wayland.Surface) {
198198 s.attached = false
199199 }
200200 if len(s.callbacks) > 0 {
201- name := ""
202- if o := s.server.outputOfSurface(s); o != nil {
203- name = o.name()
204- }
205- s.server.awaiting[name] = append(s.server.awaiting[name], s.callbacks...)
201+ s.server.queueFrames(s.server.frameOutput(s), s.callbacks)
206202 }
207203 s.callbacks = nil
208204 if s.role != nil {
@@ -198,11 +198,7 @@ func (s *surface) Commit(*wayland.Surface) {
198 s.attached = false198 s.attached = false
199 }199 }
200 if len(s.callbacks) > 0 {200 if len(s.callbacks) > 0 {
201- name := ""201+ s.server.queueFrames(s.server.frameOutput(s), s.callbacks)
202- if o := s.server.outputOfSurface(s); o != nil {
203- name = o.name()
204- }
205- s.server.awaiting[name] = append(s.server.awaiting[name], s.callbacks...)
206 }202 }
207 s.callbacks = nil203 s.callbacks = nil
208 if s.role != nil {204 if s.role != nil {

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