detached d8ac0f6

merge: layer-wayland into layer-shell

commit
d8ac0f66e0191c5ba0b8843e99e2ac106b1a3f38
author
brice · · 2026-09-23 16:12 UTC
merge of
4b5275b + 31b63a6 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +391 -2
M internal/adapters/wayland/globals.go +2 -0
@@ -11,6 +11,8 @@ import (
1111 func registerGlobals(d *server.Display, o Options, s *Server) error {
1212 for _, register := range []func() error{
1313 func() error { return registerXDG(d, s) },
14+ func() error { return registerLayer(d, o, s) },
15+ func() error { return registerXDGOutput(d, o) },
1416 func() error {
1517 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
1618 },
@@ -11,6 +11,8 @@ import (
11 func registerGlobals(d *server.Display, o Options, s *Server) error {11 func registerGlobals(d *server.Display, o Options, s *Server) error {
12 for _, register := range []func() error{12 for _, register := range []func() error{
13 func() error { return registerXDG(d, s) },13 func() error { return registerXDG(d, s) },
14+ func() error { return registerLayer(d, o, s) },
15+ func() error { return registerXDGOutput(d, o) },
14 func() error {16 func() error {
15 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })17 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
16 },18 },
A internal/adapters/wayland/layer.go +193 -0
@@ -0,0 +1,193 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
7+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
8+ "github.com/bnema/purego-libwayland/server"
9+ "sort"
10+)
11+
12+type layerShell struct {
13+ server *Server
14+ width, height uint32
15+}
16+type layerState struct {
17+ width, height, anchor uint32
18+ zone int32
19+ margin [4]int32
20+ keyboard uint32
21+ layer ports.Layer
22+}
23+type layerSurface struct {
24+ resource *wlrlayershell.ZwlrLayerSurfaceV1
25+ shell layerShell
26+ surface *surface
27+ id ports.WindowID
28+ namespace string
29+ pending, current layerState
30+ configured, acked, mapped bool
31+ serials []uint32
32+}
33+
34+// Layer objects belong to the display goroutine, like xdg windows.
35+var layerSets = map[*Server]map[ports.WindowID]*layerSurface{}
36+
37+func registerLayer(d *server.Display, o Options, s *Server) error {
38+ layerSets[s] = make(map[ports.WindowID]*layerSurface)
39+ return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) {
40+ _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s, uint32(o.OutputWidth), uint32(o.OutputHeight)})
41+ })
42+}
43+func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {}
44+func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32, w *wayland.Surface, _ *wayland.Output, layer uint32, namespace string) {
45+ if w == nil || h.server.surfaces[w.Resource] == nil {
46+ return
47+ }
48+ state := h.server.surfaces[w.Resource]
49+ if state.kind != roleNone {
50+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorRole), "surface already has role")
51+ return
52+ }
53+ if layer > 3 {
54+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorInvalidLayer), "invalid layer")
55+ return
56+ }
57+ l := &layerSurface{shell: h, surface: state, id: h.server.nextWindow, namespace: namespace}
58+ l.pending.layer = ports.Layer(layer)
59+ resource, err := wlrlayershell.NewZwlrLayerSurfaceV1(r.Client(), r.Version(), id, l)
60+ if err != nil {
61+ return
62+ }
63+ l.resource = resource
64+ h.server.nextWindow++
65+ state.kind = roleLayer
66+ state.layer = l
67+ state.role = l.commit
68+ layerSets[h.server][l.id] = l
69+ resource.OnDestroy = func() {
70+ l.unmap()
71+ delete(layerSets[h.server], l.id)
72+ state.layer = nil
73+ state.role = nil
74+ state.current, state.pending = nil, nil
75+ state.attached = false
76+ }
77+}
78+func (l *layerSurface) SetSize(_ *wlrlayershell.ZwlrLayerSurfaceV1, w, h uint32) {
79+ l.pending.width, l.pending.height = w, h
80+}
81+func (l *layerSurface) SetAnchor(r *wlrlayershell.ZwlrLayerSurfaceV1, a uint32) {
82+ if a > 15 {
83+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidAnchor), "invalid anchor")
84+ return
85+ }
86+ l.pending.anchor = a
87+}
88+func (l *layerSurface) SetExclusiveZone(_ *wlrlayershell.ZwlrLayerSurfaceV1, z int32) {
89+ l.pending.zone = z
90+}
91+func (l *layerSurface) SetMargin(_ *wlrlayershell.ZwlrLayerSurfaceV1, t, r, b, left int32) {
92+ l.pending.margin = [4]int32{t, r, b, left}
93+}
94+func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) {
95+ if k > 2 {
96+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")
97+ return
98+ }
99+ l.pending.keyboard = k
100+}
101+func (*layerSurface) GetPopup(*wlrlayershell.ZwlrLayerSurfaceV1, *xdgshell.Popup) {}
102+func (l *layerSurface) AckConfigure(r *wlrlayershell.ZwlrLayerSurfaceV1, serial uint32) {
103+ for i, v := range l.serials {
104+ if v == serial {
105+ l.serials = l.serials[i+1:]
106+ l.acked = true
107+ return
108+ }
109+ }
110+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "unknown configure serial")
111+}
112+func (*layerSurface) Destroy(*wlrlayershell.ZwlrLayerSurfaceV1) {}
113+func (l *layerSurface) SetLayer(r *wlrlayershell.ZwlrLayerSurfaceV1, v uint32) {
114+ if v > 3 {
115+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "invalid layer")
116+ return
117+ }
118+ l.pending.layer = ports.Layer(v)
119+}
120+func (*layerSurface) SetExclusiveEdge(*wlrlayershell.ZwlrLayerSurfaceV1, uint32) {}
121+func (l *layerSurface) unmap() {
122+ if !l.mapped {
123+ return
124+ }
125+ l.mapped = false
126+ l.configured = false
127+ l.acked = false
128+ l.serials = nil
129+ l.shell.server.layerChanged()
130+ l.shell.server.emitContent(ports.SurfaceContent{ID: l.id})
131+}
132+func (s *Server) layerChanged() {
133+ list := make([]ports.LayerSurface, 0)
134+ for _, l := range layerSets[s] {
135+ if !l.mapped {
136+ continue
137+ }
138+ v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace}
139+ if l.surface.current != nil {
140+ if b := s.buffers[l.surface.current.Resource]; b != nil {
141+ v.Width, v.Height = b.width, b.height
142+ }
143+ }
144+ list = append(list, v)
145+ }
146+ sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
147+ s.emit(ports.LayerChanged{Layers: list})
148+}
149+func (l *layerSurface) commit(buffer bool) {
150+ if l.surface.destroyed {
151+ l.unmap()
152+ delete(layerSets[l.shell.server], l.id)
153+ return
154+ }
155+ p := l.pending
156+ if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {
157+ l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
158+ return
159+ }
160+ changed := p != l.current
161+ sizeChanged := p.width != l.current.width || p.height != l.current.height
162+ l.current = p
163+ if !l.configured || sizeChanged {
164+ w, h := p.width, p.height
165+ if w == 0 {
166+ w = l.shell.width
167+ }
168+ if h == 0 {
169+ h = l.shell.height
170+ }
171+ l.shell.server.serial++
172+ l.resource.SendConfigure(l.shell.server.serial, w, h)
173+ l.serials = append(l.serials, l.shell.server.serial)
174+ l.configured = true
175+
176+ }
177+ if !buffer {
178+ if l.mapped {
179+ l.unmap()
180+ l.configured = false
181+ l.acked = false
182+ l.serials = nil
183+ }
184+ return
185+ }
186+ if l.acked {
187+ wasMapped := l.mapped
188+ l.mapped = true
189+ if changed || !wasMapped {
190+ l.shell.server.layerChanged()
191+ }
192+ }
193+}
@@ -0,0 +1,193 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
7+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
8+ "github.com/bnema/purego-libwayland/server"
9+ "sort"
10+)
11+
12+type layerShell struct {
13+ server *Server
14+ width, height uint32
15+}
16+type layerState struct {
17+ width, height, anchor uint32
18+ zone int32
19+ margin [4]int32
20+ keyboard uint32
21+ layer ports.Layer
22+}
23+type layerSurface struct {
24+ resource *wlrlayershell.ZwlrLayerSurfaceV1
25+ shell layerShell
26+ surface *surface
27+ id ports.WindowID
28+ namespace string
29+ pending, current layerState
30+ configured, acked, mapped bool
31+ serials []uint32
32+}
33+
34+// Layer objects belong to the display goroutine, like xdg windows.
35+var layerSets = map[*Server]map[ports.WindowID]*layerSurface{}
36+
37+func registerLayer(d *server.Display, o Options, s *Server) error {
38+ layerSets[s] = make(map[ports.WindowID]*layerSurface)
39+ return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) {
40+ _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s, uint32(o.OutputWidth), uint32(o.OutputHeight)})
41+ })
42+}
43+func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {}
44+func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32, w *wayland.Surface, _ *wayland.Output, layer uint32, namespace string) {
45+ if w == nil || h.server.surfaces[w.Resource] == nil {
46+ return
47+ }
48+ state := h.server.surfaces[w.Resource]
49+ if state.kind != roleNone {
50+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorRole), "surface already has role")
51+ return
52+ }
53+ if layer > 3 {
54+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorInvalidLayer), "invalid layer")
55+ return
56+ }
57+ l := &layerSurface{shell: h, surface: state, id: h.server.nextWindow, namespace: namespace}
58+ l.pending.layer = ports.Layer(layer)
59+ resource, err := wlrlayershell.NewZwlrLayerSurfaceV1(r.Client(), r.Version(), id, l)
60+ if err != nil {
61+ return
62+ }
63+ l.resource = resource
64+ h.server.nextWindow++
65+ state.kind = roleLayer
66+ state.layer = l
67+ state.role = l.commit
68+ layerSets[h.server][l.id] = l
69+ resource.OnDestroy = func() {
70+ l.unmap()
71+ delete(layerSets[h.server], l.id)
72+ state.layer = nil
73+ state.role = nil
74+ state.current, state.pending = nil, nil
75+ state.attached = false
76+ }
77+}
78+func (l *layerSurface) SetSize(_ *wlrlayershell.ZwlrLayerSurfaceV1, w, h uint32) {
79+ l.pending.width, l.pending.height = w, h
80+}
81+func (l *layerSurface) SetAnchor(r *wlrlayershell.ZwlrLayerSurfaceV1, a uint32) {
82+ if a > 15 {
83+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidAnchor), "invalid anchor")
84+ return
85+ }
86+ l.pending.anchor = a
87+}
88+func (l *layerSurface) SetExclusiveZone(_ *wlrlayershell.ZwlrLayerSurfaceV1, z int32) {
89+ l.pending.zone = z
90+}
91+func (l *layerSurface) SetMargin(_ *wlrlayershell.ZwlrLayerSurfaceV1, t, r, b, left int32) {
92+ l.pending.margin = [4]int32{t, r, b, left}
93+}
94+func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) {
95+ if k > 2 {
96+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")
97+ return
98+ }
99+ l.pending.keyboard = k
100+}
101+func (*layerSurface) GetPopup(*wlrlayershell.ZwlrLayerSurfaceV1, *xdgshell.Popup) {}
102+func (l *layerSurface) AckConfigure(r *wlrlayershell.ZwlrLayerSurfaceV1, serial uint32) {
103+ for i, v := range l.serials {
104+ if v == serial {
105+ l.serials = l.serials[i+1:]
106+ l.acked = true
107+ return
108+ }
109+ }
110+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "unknown configure serial")
111+}
112+func (*layerSurface) Destroy(*wlrlayershell.ZwlrLayerSurfaceV1) {}
113+func (l *layerSurface) SetLayer(r *wlrlayershell.ZwlrLayerSurfaceV1, v uint32) {
114+ if v > 3 {
115+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "invalid layer")
116+ return
117+ }
118+ l.pending.layer = ports.Layer(v)
119+}
120+func (*layerSurface) SetExclusiveEdge(*wlrlayershell.ZwlrLayerSurfaceV1, uint32) {}
121+func (l *layerSurface) unmap() {
122+ if !l.mapped {
123+ return
124+ }
125+ l.mapped = false
126+ l.configured = false
127+ l.acked = false
128+ l.serials = nil
129+ l.shell.server.layerChanged()
130+ l.shell.server.emitContent(ports.SurfaceContent{ID: l.id})
131+}
132+func (s *Server) layerChanged() {
133+ list := make([]ports.LayerSurface, 0)
134+ for _, l := range layerSets[s] {
135+ if !l.mapped {
136+ continue
137+ }
138+ v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace}
139+ if l.surface.current != nil {
140+ if b := s.buffers[l.surface.current.Resource]; b != nil {
141+ v.Width, v.Height = b.width, b.height
142+ }
143+ }
144+ list = append(list, v)
145+ }
146+ sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
147+ s.emit(ports.LayerChanged{Layers: list})
148+}
149+func (l *layerSurface) commit(buffer bool) {
150+ if l.surface.destroyed {
151+ l.unmap()
152+ delete(layerSets[l.shell.server], l.id)
153+ return
154+ }
155+ p := l.pending
156+ if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {
157+ l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
158+ return
159+ }
160+ changed := p != l.current
161+ sizeChanged := p.width != l.current.width || p.height != l.current.height
162+ l.current = p
163+ if !l.configured || sizeChanged {
164+ w, h := p.width, p.height
165+ if w == 0 {
166+ w = l.shell.width
167+ }
168+ if h == 0 {
169+ h = l.shell.height
170+ }
171+ l.shell.server.serial++
172+ l.resource.SendConfigure(l.shell.server.serial, w, h)
173+ l.serials = append(l.serials, l.shell.server.serial)
174+ l.configured = true
175+
176+ }
177+ if !buffer {
178+ if l.mapped {
179+ l.unmap()
180+ l.configured = false
181+ l.acked = false
182+ l.serials = nil
183+ }
184+ return
185+ }
186+ if l.acked {
187+ wasMapped := l.mapped
188+ l.mapped = true
189+ if changed || !wasMapped {
190+ l.shell.server.layerChanged()
191+ }
192+ }
193+}
A internal/adapters/wayland/layer_test.go +142 -0
@@ -0,0 +1,142 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
7+ "github.com/bnema/wlturbo"
8+ "golang.org/x/sys/unix"
9+ "os"
10+ "os/exec"
11+ "testing"
12+ "time"
13+)
14+
15+type layerProxy struct {
16+ wlturbo.BaseProxy
17+ configured chan [3]uint32
18+}
19+
20+func (p *layerProxy) Dispatch(e *wlturbo.Event) {
21+ if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventConfigure) {
22+ p.configured <- [3]uint32{e.Uint32(), e.Uint32(), e.Uint32()}
23+ }
24+}
25+func layerProtocol(t *testing.T, c *wlturbo.Display) (uint32, uint32) {
26+ t.Helper()
27+ comp := bindProtocol(t, c, "wl_compositor")
28+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
29+ surf := c.AllocateID()
30+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
31+ registerProtocol(t, c, surf)
32+ layer := c.AllocateID()
33+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, uint32(0), uint32(ports.LayerTop), "test")
34+ return surf, layer
35+}
36+func TestLayerInvalidSize(t *testing.T) {
37+ s, _, _, dir := lifecycleServer(t)
38+ c := protocolClient(t, s, dir)
39+ surf, layer := layerProtocol(t, c)
40+ registerProtocol(t, c, layer)
41+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
42+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(ports.AnchorTop))
43+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
44+ expectProtocolError(t, c, layer, uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize))
45+}
46+func TestLayerLifecycle(t *testing.T) {
47+ s, events, _, dir := lifecycleServer(t)
48+ c := protocolClient(t, s, dir)
49+ surf, layer := layerProtocol(t, c)
50+ proxy := &layerProxy{configured: make(chan [3]uint32, 2)}
51+ proxy.SetID(layer)
52+ c.Context().Register(proxy)
53+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
54+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
55+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30))
56+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
57+ if err := c.Roundtrip(); err != nil {
58+ t.Fatal(err)
59+ }
60+ var config [3]uint32
61+ select {
62+ case config = <-proxy.configured:
63+ case <-time.After(2 * time.Second):
64+ t.Fatal("configure timeout")
65+ }
66+ if config[1] != 1920 || config[2] != 30 {
67+ t.Fatalf("configure: %v", config)
68+ }
69+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, config[0])
70+ shm := bindProtocol(t, c, "wl_shm")
71+ registerProtocol(t, c, shm)
72+ fd, err := unix.MemfdCreate("layer-buffer", 0)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ defer unix.Close(fd)
77+ if err = unix.Ftruncate(fd, 1920*30*4); err != nil {
78+ t.Fatal(err)
79+ }
80+ pool, buf := c.AllocateID(), c.AllocateID()
81+ registerProtocol(t, c, pool)
82+ registerProtocol(t, c, buf)
83+ if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil {
84+ t.Fatal(err)
85+ }
86+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888))
87+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
88+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
89+ if err = c.Roundtrip(); err != nil {
90+ t.Fatal(err)
91+ }
92+ select {
93+ case ev := <-events:
94+ v, ok := ev.(ports.LayerChanged)
95+ if !ok || len(v.Layers) != 1 || v.Layers[0].Anchor != 13 || v.Layers[0].ExclusiveZone != 30 || v.Layers[0].Width != 1920 || v.Layers[0].Height != 30 {
96+ t.Fatalf("mapped: %#v", ev)
97+ }
98+ case <-time.After(2 * time.Second):
99+ t.Fatal("map timeout")
100+ }
101+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
102+ if err = c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ select {
106+ case ev := <-events:
107+ v, ok := ev.(ports.LayerChanged)
108+ if !ok || len(v.Layers) != 0 {
109+ t.Fatalf("destroy: %#v", ev)
110+ }
111+ case <-time.After(2 * time.Second):
112+ t.Fatal("destroy timeout")
113+ }
114+}
115+func TestWaybarLayer(t *testing.T) {
116+ tool, err := exec.LookPath("waybar")
117+ if err != nil {
118+ t.Skip("waybar missing")
119+ }
120+ s, events, _, dir := keyboardServer(t)
121+ home := t.TempDir()
122+ config := home + "/config.json"
123+ style := home + "/style.css"
124+ if err = os.WriteFile(config, []byte(`{"layer":"top","position":"top","height":30,"modules-left":["clock"]}`), 0600); err != nil {
125+ t.Fatal(err)
126+ }
127+ if err = os.WriteFile(style, nil, 0600); err != nil {
128+ t.Fatal(err)
129+ }
130+ _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home}, "-c", config, "-s", style)
131+ select {
132+ case ev := <-events:
133+ v, ok := ev.(ports.LayerChanged)
134+ if !ok || len(v.Layers) != 1 || v.Layers[0].Height != 30 {
135+ t.Fatalf("waybar event: %#v stderr: %s", ev, output.String())
136+ }
137+ case err := <-done:
138+ t.Fatalf("waybar exited: %v stderr: %s", err, output.String())
139+ case <-time.After(10 * time.Second):
140+ t.Fatalf("waybar timeout stderr: %s", output.String())
141+ }
142+}
@@ -0,0 +1,142 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
7+ "github.com/bnema/wlturbo"
8+ "golang.org/x/sys/unix"
9+ "os"
10+ "os/exec"
11+ "testing"
12+ "time"
13+)
14+
15+type layerProxy struct {
16+ wlturbo.BaseProxy
17+ configured chan [3]uint32
18+}
19+
20+func (p *layerProxy) Dispatch(e *wlturbo.Event) {
21+ if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventConfigure) {
22+ p.configured <- [3]uint32{e.Uint32(), e.Uint32(), e.Uint32()}
23+ }
24+}
25+func layerProtocol(t *testing.T, c *wlturbo.Display) (uint32, uint32) {
26+ t.Helper()
27+ comp := bindProtocol(t, c, "wl_compositor")
28+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
29+ surf := c.AllocateID()
30+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
31+ registerProtocol(t, c, surf)
32+ layer := c.AllocateID()
33+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, uint32(0), uint32(ports.LayerTop), "test")
34+ return surf, layer
35+}
36+func TestLayerInvalidSize(t *testing.T) {
37+ s, _, _, dir := lifecycleServer(t)
38+ c := protocolClient(t, s, dir)
39+ surf, layer := layerProtocol(t, c)
40+ registerProtocol(t, c, layer)
41+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
42+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(ports.AnchorTop))
43+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
44+ expectProtocolError(t, c, layer, uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize))
45+}
46+func TestLayerLifecycle(t *testing.T) {
47+ s, events, _, dir := lifecycleServer(t)
48+ c := protocolClient(t, s, dir)
49+ surf, layer := layerProtocol(t, c)
50+ proxy := &layerProxy{configured: make(chan [3]uint32, 2)}
51+ proxy.SetID(layer)
52+ c.Context().Register(proxy)
53+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
54+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
55+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30))
56+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
57+ if err := c.Roundtrip(); err != nil {
58+ t.Fatal(err)
59+ }
60+ var config [3]uint32
61+ select {
62+ case config = <-proxy.configured:
63+ case <-time.After(2 * time.Second):
64+ t.Fatal("configure timeout")
65+ }
66+ if config[1] != 1920 || config[2] != 30 {
67+ t.Fatalf("configure: %v", config)
68+ }
69+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, config[0])
70+ shm := bindProtocol(t, c, "wl_shm")
71+ registerProtocol(t, c, shm)
72+ fd, err := unix.MemfdCreate("layer-buffer", 0)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ defer unix.Close(fd)
77+ if err = unix.Ftruncate(fd, 1920*30*4); err != nil {
78+ t.Fatal(err)
79+ }
80+ pool, buf := c.AllocateID(), c.AllocateID()
81+ registerProtocol(t, c, pool)
82+ registerProtocol(t, c, buf)
83+ if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil {
84+ t.Fatal(err)
85+ }
86+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888))
87+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
88+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
89+ if err = c.Roundtrip(); err != nil {
90+ t.Fatal(err)
91+ }
92+ select {
93+ case ev := <-events:
94+ v, ok := ev.(ports.LayerChanged)
95+ if !ok || len(v.Layers) != 1 || v.Layers[0].Anchor != 13 || v.Layers[0].ExclusiveZone != 30 || v.Layers[0].Width != 1920 || v.Layers[0].Height != 30 {
96+ t.Fatalf("mapped: %#v", ev)
97+ }
98+ case <-time.After(2 * time.Second):
99+ t.Fatal("map timeout")
100+ }
101+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
102+ if err = c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ select {
106+ case ev := <-events:
107+ v, ok := ev.(ports.LayerChanged)
108+ if !ok || len(v.Layers) != 0 {
109+ t.Fatalf("destroy: %#v", ev)
110+ }
111+ case <-time.After(2 * time.Second):
112+ t.Fatal("destroy timeout")
113+ }
114+}
115+func TestWaybarLayer(t *testing.T) {
116+ tool, err := exec.LookPath("waybar")
117+ if err != nil {
118+ t.Skip("waybar missing")
119+ }
120+ s, events, _, dir := keyboardServer(t)
121+ home := t.TempDir()
122+ config := home + "/config.json"
123+ style := home + "/style.css"
124+ if err = os.WriteFile(config, []byte(`{"layer":"top","position":"top","height":30,"modules-left":["clock"]}`), 0600); err != nil {
125+ t.Fatal(err)
126+ }
127+ if err = os.WriteFile(style, nil, 0600); err != nil {
128+ t.Fatal(err)
129+ }
130+ _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home}, "-c", config, "-s", style)
131+ select {
132+ case ev := <-events:
133+ v, ok := ev.(ports.LayerChanged)
134+ if !ok || len(v.Layers) != 1 || v.Layers[0].Height != 30 {
135+ t.Fatalf("waybar event: %#v stderr: %s", ev, output.String())
136+ }
137+ case err := <-done:
138+ t.Fatalf("waybar exited: %v stderr: %s", err, output.String())
139+ case <-time.After(10 * time.Second):
140+ t.Fatalf("waybar timeout stderr: %s", output.String())
141+ }
142+}
M internal/adapters/wayland/surface.go +17 -2
@@ -1,7 +1,9 @@
11 package wayland
22
33 import (
4+ "github.com/bnema/nefertty/internal/ports"
45 "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
57 "github.com/bnema/purego-libwayland/protocol/xdgshell"
68 )
79
@@ -11,11 +13,13 @@ const (
1113 roleNone roleKind = iota
1214 roleXDG
1315 roleSubsurface
16+ roleLayer
1417 )
1518
1619 type surface struct {
1720 kind roleKind
1821 xdg *xdgSurface
22+ layer *layerSurface
1923 server *Server
2024 current, pending *wayland.Buffer
2125 attached bool
@@ -48,6 +52,10 @@ func (s *surface) Frame(r *wayland.Surface, id uint32) {
4852 }
4953 func (s *surface) Commit(*wayland.Surface) {
5054 fresh := s.attached && s.pending != nil
55+ if s.layer != nil && s.attached && s.pending != nil && !s.layer.acked {
56+ s.layer.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "buffer before configure ack")
57+ return
58+ }
5159 if s.xdg != nil && s.attached && s.pending != nil && !s.xdg.acked {
5260 s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack")
5361 return
@@ -68,10 +76,17 @@ func (s *surface) Commit(*wayland.Surface) {
6876 if s.role != nil {
6977 s.role(s.current != nil)
7078 }
71- if fresh && s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped && s.server.channels.Contents != nil {
79+ id := ports.WindowID(0)
80+ if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
81+ id = s.xdg.window.id
82+ }
83+ if s.layer != nil && s.layer.mapped {
84+ id = s.layer.id
85+ }
86+ if fresh && id != 0 && s.server.channels.Contents != nil {
7287 b := s.current
7388 if state, ok := s.server.buffers[b.Resource]; ok {
74- if c, ok := state.content(s.xdg.window.id); ok {
89+ if c, ok := state.content(id); ok {
7590 s.server.emitContent(c)
7691 } else {
7792 state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated")
@@ -1,7 +1,9 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "github.com/bnema/nefertty/internal/ports"
4 "github.com/bnema/purego-libwayland/protocol/wayland"5 "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
5 "github.com/bnema/purego-libwayland/protocol/xdgshell"7 "github.com/bnema/purego-libwayland/protocol/xdgshell"
6 )8 )
7 9
@@ -11,11 +13,13 @@ const (
11 roleNone roleKind = iota13 roleNone roleKind = iota
12 roleXDG14 roleXDG
13 roleSubsurface15 roleSubsurface
16+ roleLayer
14 )17 )
15 18
16 type surface struct {19 type surface struct {
17 kind roleKind20 kind roleKind
18 xdg *xdgSurface21 xdg *xdgSurface
22+ layer *layerSurface
19 server *Server23 server *Server
20 current, pending *wayland.Buffer24 current, pending *wayland.Buffer
21 attached bool25 attached bool
@@ -48,6 +52,10 @@ func (s *surface) Frame(r *wayland.Surface, id uint32) {
48 }52 }
49 func (s *surface) Commit(*wayland.Surface) {53 func (s *surface) Commit(*wayland.Surface) {
50 fresh := s.attached && s.pending != nil54 fresh := s.attached && s.pending != nil
55+ if s.layer != nil && s.attached && s.pending != nil && !s.layer.acked {
56+ s.layer.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "buffer before configure ack")
57+ return
58+ }
51 if s.xdg != nil && s.attached && s.pending != nil && !s.xdg.acked {59 if s.xdg != nil && s.attached && s.pending != nil && !s.xdg.acked {
52 s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack")60 s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack")
53 return61 return
@@ -68,10 +76,17 @@ func (s *surface) Commit(*wayland.Surface) {
68 if s.role != nil {76 if s.role != nil {
69 s.role(s.current != nil)77 s.role(s.current != nil)
70 }78 }
71- if fresh && s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped && s.server.channels.Contents != nil {79+ id := ports.WindowID(0)
80+ if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
81+ id = s.xdg.window.id
82+ }
83+ if s.layer != nil && s.layer.mapped {
84+ id = s.layer.id
85+ }
86+ if fresh && id != 0 && s.server.channels.Contents != nil {
72 b := s.current87 b := s.current
73 if state, ok := s.server.buffers[b.Resource]; ok {88 if state, ok := s.server.buffers[b.Resource]; ok {
74- if c, ok := state.content(s.xdg.window.id); ok {89+ if c, ok := state.content(id); ok {
75 s.server.emitContent(c)90 s.server.emitContent(c)
76 } else {91 } else {
77 state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated")92 state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated")
A internal/adapters/wayland/xdgoutput.go +37 -0
@@ -0,0 +1,37 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/wayland"
5+ "github.com/bnema/purego-libwayland/protocol/xdgoutput"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+type xdgOutputManager struct{ width, height int32 }
10+
11+func registerXDGOutput(d *server.Display, o Options) error {
12+ return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
13+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight)})
14+ })
15+}
16+func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
17+func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, output *wayland.Output) {
18+ x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{})
19+ if err != nil {
20+ return
21+ }
22+ x.SendLogicalPosition(0, 0)
23+ x.SendLogicalSize(m.width, m.height)
24+ if r.Version() >= 2 {
25+ x.SendName("HEADLESS-1")
26+ x.SendDescription("NeferTTY headless output")
27+ }
28+ if r.Version() < 3 {
29+ x.SendDone()
30+ } else if output != nil && output.Version() >= 2 {
31+ output.SendDone()
32+ }
33+}
34+
35+type xdgOutput struct{}
36+
37+func (xdgOutput) Destroy(*xdgoutput.ZxdgOutputV1) {}
@@ -0,0 +1,37 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/wayland"
5+ "github.com/bnema/purego-libwayland/protocol/xdgoutput"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+type xdgOutputManager struct{ width, height int32 }
10+
11+func registerXDGOutput(d *server.Display, o Options) error {
12+ return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
13+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight)})
14+ })
15+}
16+func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
17+func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, output *wayland.Output) {
18+ x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{})
19+ if err != nil {
20+ return
21+ }
22+ x.SendLogicalPosition(0, 0)
23+ x.SendLogicalSize(m.width, m.height)
24+ if r.Version() >= 2 {
25+ x.SendName("HEADLESS-1")
26+ x.SendDescription("NeferTTY headless output")
27+ }
28+ if r.Version() < 3 {
29+ x.SendDone()
30+ } else if output != nil && output.Version() >= 2 {
31+ output.SendDone()
32+ }
33+}
34+
35+type xdgOutput struct{}
36+
37+func (xdgOutput) Destroy(*xdgoutput.ZxdgOutputV1) {}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close