detached b5740ee

merge: layer-shell

commit
b5740eee49a2a74a71a3953d548174e2bc559f16
author
brice · · 2026-09-23 16:22 UTC
merge of
74a0fea + 2f7d458 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +822 -5
M internal/adapters/headless/output_test.go +34 -0
@@ -141,3 +141,37 @@ func TestWritePNGAtomic(t *testing.T) {
141141 t.Fatalf("unexpected files: %v", entries)
142142 }
143143 }
144+
145+func TestRunPassesLayerContent(t *testing.T) {
146+ scenes := make(chan ports.Scene, 1)
147+ contents := make(chan ports.SurfaceContent, 1)
148+ layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
149+ scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
150+ contents <- ports.SurfaceContent{ID: 42, Width: 1, Height: 1, Stride: 4, Pixels: []byte{0, 0, 255, 255}}
151+ r := new(fakeRenderer)
152+ ctx, cancel := context.WithCancel(context.Background())
153+ done := make(chan error, 1)
154+ go func() {
155+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, contents)
156+ }()
157+ deadline := time.After(3 * time.Second)
158+ for {
159+ r.mu.Lock()
160+ passed := len(r.contents) > 0 && len(r.frames[0].Layers) == 1 && r.contents[0][42].Pixels != nil
161+ r.mu.Unlock()
162+ if passed {
163+ break
164+ }
165+ select {
166+ case <-deadline:
167+ cancel()
168+ <-done
169+ t.Fatal("layer content not passed to renderer")
170+ case <-time.After(10 * time.Millisecond):
171+ }
172+ }
173+ cancel()
174+ if err := <-done; err != nil {
175+ t.Fatal(err)
176+ }
177+}
@@ -141,3 +141,37 @@ func TestWritePNGAtomic(t *testing.T) {
141 t.Fatalf("unexpected files: %v", entries)141 t.Fatalf("unexpected files: %v", entries)
142 }142 }
143 }143 }
144+
145+func TestRunPassesLayerContent(t *testing.T) {
146+ scenes := make(chan ports.Scene, 1)
147+ contents := make(chan ports.SurfaceContent, 1)
148+ layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
149+ scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
150+ contents <- ports.SurfaceContent{ID: 42, Width: 1, Height: 1, Stride: 4, Pixels: []byte{0, 0, 255, 255}}
151+ r := new(fakeRenderer)
152+ ctx, cancel := context.WithCancel(context.Background())
153+ done := make(chan error, 1)
154+ go func() {
155+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, contents)
156+ }()
157+ deadline := time.After(3 * time.Second)
158+ for {
159+ r.mu.Lock()
160+ passed := len(r.contents) > 0 && len(r.frames[0].Layers) == 1 && r.contents[0][42].Pixels != nil
161+ r.mu.Unlock()
162+ if passed {
163+ break
164+ }
165+ select {
166+ case <-deadline:
167+ cancel()
168+ <-done
169+ t.Fatal("layer content not passed to renderer")
170+ case <-time.After(10 * time.Millisecond):
171+ }
172+ }
173+ cancel()
174+ if err := <-done; err != nil {
175+ t.Fatal(err)
176+ }
177+}
M internal/adapters/vulkan/renderer.go +22 -0
@@ -336,6 +336,27 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
336336 uploads = append(uploads, u)
337337 used += n
338338 }
339+ fullscreen := false
340+ for _, w := range s.Windows {
341+ fullscreen = fullscreen || (!w.Hidden && w.Fullscreen)
342+ }
343+ addLayers := func(afterWindows bool) {
344+ for _, layer := range s.Layers {
345+ if (layer.Layer >= ports.LayerTop) != afterWindows ||
346+ (fullscreen && (layer.Layer == ports.LayerBottom || layer.Layer == ports.LayerTop)) ||
347+ layer.Rect.W <= 0 || layer.Rect.H <= 0 {
348+ continue
349+ }
350+ content := contents[layer.ID]
351+ width, height := min(content.Width, layer.Rect.W), min(content.Height, layer.Rect.H)
352+ if content.Pixels == nil || width <= 0 || height <= 0 || content.Stride < width*4 || len(content.Pixels) < (height-1)*content.Stride+width*4 {
353+ continue
354+ }
355+ x, y := layer.Rect.X, layer.Rect.Y
356+ add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y))
357+ }
358+ }
359+ addLayers(false)
339360 for _, w := range s.Windows {
340361 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
341362 continue
@@ -358,6 +379,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
358379 }
359380 }
360381 }
382+ addLayers(true)
361383 if err := r.ensureStaging(used); err != nil {
362384 return err
363385 }
@@ -336,6 +336,27 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
336 uploads = append(uploads, u)336 uploads = append(uploads, u)
337 used += n337 used += n
338 }338 }
339+ fullscreen := false
340+ for _, w := range s.Windows {
341+ fullscreen = fullscreen || (!w.Hidden && w.Fullscreen)
342+ }
343+ addLayers := func(afterWindows bool) {
344+ for _, layer := range s.Layers {
345+ if (layer.Layer >= ports.LayerTop) != afterWindows ||
346+ (fullscreen && (layer.Layer == ports.LayerBottom || layer.Layer == ports.LayerTop)) ||
347+ layer.Rect.W <= 0 || layer.Rect.H <= 0 {
348+ continue
349+ }
350+ content := contents[layer.ID]
351+ width, height := min(content.Width, layer.Rect.W), min(content.Height, layer.Rect.H)
352+ if content.Pixels == nil || width <= 0 || height <= 0 || content.Stride < width*4 || len(content.Pixels) < (height-1)*content.Stride+width*4 {
353+ continue
354+ }
355+ x, y := layer.Rect.X, layer.Rect.Y
356+ add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y))
357+ }
358+ }
359+ addLayers(false)
339 for _, w := range s.Windows {360 for _, w := range s.Windows {
340 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {361 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
341 continue362 continue
@@ -358,6 +379,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
358 }379 }
359 }380 }
360 }381 }
382+ addLayers(true)
361 if err := r.ensureStaging(used); err != nil {383 if err := r.ensureStaging(used); err != nil {
362 return err384 return err
363 }385 }
M internal/adapters/vulkan/renderer_test.go +38 -0
@@ -145,3 +145,41 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
145145 }
146146 }
147147 }
148+
149+func TestRendererLayers(t *testing.T) {
150+ r, err := New(64, 48)
151+ if err != nil {
152+ t.Skipf("Vulkan unavailable: %v", err)
153+ }
154+ defer r.Close()
155+ red := make([]byte, 64*8*4)
156+ for i := 0; i < len(red); i += 4 {
157+ copy(red[i:i+4], []byte{0, 0, 255, 0})
158+ }
159+ contents := map[ports.WindowID]ports.SurfaceContent{2: {ID: 2, Width: 64, Height: 8, Stride: 64 * 4, Opaque: true, Pixels: red}}
160+ scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}}, Layers: []ports.SceneLayer{{ID: 2, Layer: ports.LayerTop, Rect: ports.Rect{W: 64, H: 8}}}}
161+ wc := windowColor(1)
162+ window := color.RGBA{wc[0], wc[1], wc[2], 255}
163+ check := func(want color.RGBA) {
164+ t.Helper()
165+ if err := r.Render(scene, contents); err != nil {
166+ t.Fatal(err)
167+ }
168+ if got := r.Pixels().RGBAAt(5, 2); got != want {
169+ t.Errorf("pixel = %v, want %v", got, want)
170+ }
171+ }
172+ check(color.RGBA{255, 0, 0, 255})
173+ scene.Layers[0].Layer = ports.LayerBottom
174+ check(window)
175+ scene.Windows[0].Fullscreen = true
176+ scene.Layers[0].Layer = ports.LayerTop
177+ check(window)
178+ scene.Layers[0].Layer = ports.LayerOverlay
179+ check(color.RGBA{255, 0, 0, 255})
180+ scene.Windows[0].Hidden = true
181+ scene.Layers[0].Layer = ports.LayerBottom
182+ check(color.RGBA{255, 0, 0, 255})
183+ delete(contents, 2)
184+ check(color.RGBA{16, 32, 48, 255})
185+}
@@ -145,3 +145,41 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
145 }145 }
146 }146 }
147 }147 }
148+
149+func TestRendererLayers(t *testing.T) {
150+ r, err := New(64, 48)
151+ if err != nil {
152+ t.Skipf("Vulkan unavailable: %v", err)
153+ }
154+ defer r.Close()
155+ red := make([]byte, 64*8*4)
156+ for i := 0; i < len(red); i += 4 {
157+ copy(red[i:i+4], []byte{0, 0, 255, 0})
158+ }
159+ contents := map[ports.WindowID]ports.SurfaceContent{2: {ID: 2, Width: 64, Height: 8, Stride: 64 * 4, Opaque: true, Pixels: red}}
160+ scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}}, Layers: []ports.SceneLayer{{ID: 2, Layer: ports.LayerTop, Rect: ports.Rect{W: 64, H: 8}}}}
161+ wc := windowColor(1)
162+ window := color.RGBA{wc[0], wc[1], wc[2], 255}
163+ check := func(want color.RGBA) {
164+ t.Helper()
165+ if err := r.Render(scene, contents); err != nil {
166+ t.Fatal(err)
167+ }
168+ if got := r.Pixels().RGBAAt(5, 2); got != want {
169+ t.Errorf("pixel = %v, want %v", got, want)
170+ }
171+ }
172+ check(color.RGBA{255, 0, 0, 255})
173+ scene.Layers[0].Layer = ports.LayerBottom
174+ check(window)
175+ scene.Windows[0].Fullscreen = true
176+ scene.Layers[0].Layer = ports.LayerTop
177+ check(window)
178+ scene.Layers[0].Layer = ports.LayerOverlay
179+ check(color.RGBA{255, 0, 0, 255})
180+ scene.Windows[0].Hidden = true
181+ scene.Layers[0].Layer = ports.LayerBottom
182+ check(color.RGBA{255, 0, 0, 255})
183+ delete(contents, 2)
184+ check(color.RGBA{16, 32, 48, 255})
185+}
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 +195 -0
@@ -0,0 +1,195 @@
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+func registerLayer(d *server.Display, o Options, s *Server) error {
35+ return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) {
36+ _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s, uint32(o.OutputWidth), uint32(o.OutputHeight)})
37+ })
38+}
39+func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {}
40+func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32, w *wayland.Surface, _ *wayland.Output, layer uint32, namespace string) {
41+ if w == nil || h.server.surfaces[w.Resource] == nil {
42+ return
43+ }
44+ state := h.server.surfaces[w.Resource]
45+ if state.kind != roleNone || (state.attached && state.pending != nil) || state.current != nil {
46+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorAlreadyConstructed), "surface already constructed")
47+ return
48+ }
49+ if layer > 3 {
50+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorInvalidLayer), "invalid layer")
51+ return
52+ }
53+ l := &layerSurface{shell: h, surface: state, id: h.server.nextWindow, namespace: namespace}
54+ l.pending.layer = ports.Layer(layer)
55+ resource, err := wlrlayershell.NewZwlrLayerSurfaceV1(r.Client(), r.Version(), id, l)
56+ if err != nil {
57+ return
58+ }
59+ l.resource = resource
60+ h.server.nextWindow++
61+ state.kind = roleLayer
62+ state.layer = l
63+ state.role = l.commit
64+ h.server.layers[l.id] = l
65+ resource.OnDestroy = func() {
66+ l.unmap()
67+ delete(h.server.layers, l.id)
68+ state.layer = nil
69+ state.role = nil
70+ state.current, state.pending = nil, nil
71+ state.attached = false
72+ }
73+}
74+func (l *layerSurface) SetSize(_ *wlrlayershell.ZwlrLayerSurfaceV1, w, h uint32) {
75+ l.pending.width, l.pending.height = w, h
76+}
77+func (l *layerSurface) SetAnchor(r *wlrlayershell.ZwlrLayerSurfaceV1, a uint32) {
78+ if a > 15 {
79+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidAnchor), "invalid anchor")
80+ return
81+ }
82+ l.pending.anchor = a
83+}
84+func (l *layerSurface) SetExclusiveZone(_ *wlrlayershell.ZwlrLayerSurfaceV1, z int32) {
85+ l.pending.zone = z
86+}
87+func (l *layerSurface) SetMargin(_ *wlrlayershell.ZwlrLayerSurfaceV1, t, r, b, left int32) {
88+ l.pending.margin = [4]int32{t, r, b, left}
89+}
90+
91+// Keyboard interactivity is deferred; layer-shell is capped at v4.
92+func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) {
93+ if k > 2 {
94+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")
95+ return
96+ }
97+ l.pending.keyboard = k
98+}
99+
100+// Layer popups are deferred; layer-shell is capped at v4.
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+
121+// Exclusive edge is not implemented; layer-shell is capped at v4.
122+func (*layerSurface) SetExclusiveEdge(*wlrlayershell.ZwlrLayerSurfaceV1, uint32) {}
123+func (l *layerSurface) unmap() {
124+ if !l.mapped {
125+ return
126+ }
127+ l.mapped = false
128+ l.configured = false
129+ l.acked = false
130+ l.serials = nil
131+ l.shell.server.layerChanged()
132+ l.shell.server.emitContent(ports.SurfaceContent{ID: l.id})
133+}
134+func (s *Server) layerChanged() {
135+ list := make([]ports.LayerSurface, 0)
136+ for _, l := range s.layers {
137+ if !l.mapped {
138+ continue
139+ }
140+ 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}
141+ if l.surface.current != nil {
142+ if b := s.buffers[l.surface.current.Resource]; b != nil {
143+ v.Width, v.Height = b.width, b.height
144+ }
145+ }
146+ list = append(list, v)
147+ }
148+ sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
149+ s.emit(ports.LayerChanged{Layers: list})
150+}
151+func (l *layerSurface) commit(buffer bool) {
152+ if l.surface.destroyed {
153+ l.unmap()
154+ delete(l.shell.server.layers, l.id)
155+ return
156+ }
157+ p := l.pending
158+ 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) {
159+ l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
160+ return
161+ }
162+ changed := p != l.current
163+ sizeChanged := p.width != l.current.width || p.height != l.current.height
164+ l.current = p
165+ if !l.configured || sizeChanged {
166+ w, h := p.width, p.height
167+ if w == 0 {
168+ w = l.shell.width
169+ }
170+ if h == 0 {
171+ h = l.shell.height
172+ }
173+ l.shell.server.serial++
174+ l.resource.SendConfigure(l.shell.server.serial, w, h)
175+ l.serials = append(l.serials, l.shell.server.serial)
176+ l.configured = true
177+
178+ }
179+ if !buffer {
180+ if l.mapped {
181+ l.unmap()
182+ l.configured = false
183+ l.acked = false
184+ l.serials = nil
185+ }
186+ return
187+ }
188+ if l.acked {
189+ wasMapped := l.mapped
190+ l.mapped = true
191+ if changed || !wasMapped {
192+ l.shell.server.layerChanged()
193+ }
194+ }
195+}
@@ -0,0 +1,195 @@
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+func registerLayer(d *server.Display, o Options, s *Server) error {
35+ return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) {
36+ _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s, uint32(o.OutputWidth), uint32(o.OutputHeight)})
37+ })
38+}
39+func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {}
40+func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32, w *wayland.Surface, _ *wayland.Output, layer uint32, namespace string) {
41+ if w == nil || h.server.surfaces[w.Resource] == nil {
42+ return
43+ }
44+ state := h.server.surfaces[w.Resource]
45+ if state.kind != roleNone || (state.attached && state.pending != nil) || state.current != nil {
46+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorAlreadyConstructed), "surface already constructed")
47+ return
48+ }
49+ if layer > 3 {
50+ r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorInvalidLayer), "invalid layer")
51+ return
52+ }
53+ l := &layerSurface{shell: h, surface: state, id: h.server.nextWindow, namespace: namespace}
54+ l.pending.layer = ports.Layer(layer)
55+ resource, err := wlrlayershell.NewZwlrLayerSurfaceV1(r.Client(), r.Version(), id, l)
56+ if err != nil {
57+ return
58+ }
59+ l.resource = resource
60+ h.server.nextWindow++
61+ state.kind = roleLayer
62+ state.layer = l
63+ state.role = l.commit
64+ h.server.layers[l.id] = l
65+ resource.OnDestroy = func() {
66+ l.unmap()
67+ delete(h.server.layers, l.id)
68+ state.layer = nil
69+ state.role = nil
70+ state.current, state.pending = nil, nil
71+ state.attached = false
72+ }
73+}
74+func (l *layerSurface) SetSize(_ *wlrlayershell.ZwlrLayerSurfaceV1, w, h uint32) {
75+ l.pending.width, l.pending.height = w, h
76+}
77+func (l *layerSurface) SetAnchor(r *wlrlayershell.ZwlrLayerSurfaceV1, a uint32) {
78+ if a > 15 {
79+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidAnchor), "invalid anchor")
80+ return
81+ }
82+ l.pending.anchor = a
83+}
84+func (l *layerSurface) SetExclusiveZone(_ *wlrlayershell.ZwlrLayerSurfaceV1, z int32) {
85+ l.pending.zone = z
86+}
87+func (l *layerSurface) SetMargin(_ *wlrlayershell.ZwlrLayerSurfaceV1, t, r, b, left int32) {
88+ l.pending.margin = [4]int32{t, r, b, left}
89+}
90+
91+// Keyboard interactivity is deferred; layer-shell is capped at v4.
92+func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) {
93+ if k > 2 {
94+ r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")
95+ return
96+ }
97+ l.pending.keyboard = k
98+}
99+
100+// Layer popups are deferred; layer-shell is capped at v4.
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+
121+// Exclusive edge is not implemented; layer-shell is capped at v4.
122+func (*layerSurface) SetExclusiveEdge(*wlrlayershell.ZwlrLayerSurfaceV1, uint32) {}
123+func (l *layerSurface) unmap() {
124+ if !l.mapped {
125+ return
126+ }
127+ l.mapped = false
128+ l.configured = false
129+ l.acked = false
130+ l.serials = nil
131+ l.shell.server.layerChanged()
132+ l.shell.server.emitContent(ports.SurfaceContent{ID: l.id})
133+}
134+func (s *Server) layerChanged() {
135+ list := make([]ports.LayerSurface, 0)
136+ for _, l := range s.layers {
137+ if !l.mapped {
138+ continue
139+ }
140+ 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}
141+ if l.surface.current != nil {
142+ if b := s.buffers[l.surface.current.Resource]; b != nil {
143+ v.Width, v.Height = b.width, b.height
144+ }
145+ }
146+ list = append(list, v)
147+ }
148+ sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
149+ s.emit(ports.LayerChanged{Layers: list})
150+}
151+func (l *layerSurface) commit(buffer bool) {
152+ if l.surface.destroyed {
153+ l.unmap()
154+ delete(l.shell.server.layers, l.id)
155+ return
156+ }
157+ p := l.pending
158+ 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) {
159+ l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
160+ return
161+ }
162+ changed := p != l.current
163+ sizeChanged := p.width != l.current.width || p.height != l.current.height
164+ l.current = p
165+ if !l.configured || sizeChanged {
166+ w, h := p.width, p.height
167+ if w == 0 {
168+ w = l.shell.width
169+ }
170+ if h == 0 {
171+ h = l.shell.height
172+ }
173+ l.shell.server.serial++
174+ l.resource.SendConfigure(l.shell.server.serial, w, h)
175+ l.serials = append(l.serials, l.shell.server.serial)
176+ l.configured = true
177+
178+ }
179+ if !buffer {
180+ if l.mapped {
181+ l.unmap()
182+ l.configured = false
183+ l.acked = false
184+ l.serials = nil
185+ }
186+ return
187+ }
188+ if l.acked {
189+ wasMapped := l.mapped
190+ l.mapped = true
191+ if changed || !wasMapped {
192+ l.shell.server.layerChanged()
193+ }
194+ }
195+}
A internal/adapters/wayland/layer_test.go +224 -0
@@ -0,0 +1,224 @@
1+package wayland
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/nefertty/internal/ports"
6+ "github.com/bnema/purego-libwayland/protocol/wayland"
7+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
8+ "github.com/bnema/wlturbo"
9+ "golang.org/x/sys/unix"
10+ "os"
11+ "os/exec"
12+ "testing"
13+ "time"
14+)
15+
16+type layerProxy struct {
17+ wlturbo.BaseProxy
18+ configured chan [3]uint32
19+}
20+
21+func (p *layerProxy) Dispatch(e *wlturbo.Event) {
22+ if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventConfigure) {
23+ p.configured <- [3]uint32{e.Uint32(), e.Uint32(), e.Uint32()}
24+ }
25+}
26+func layerProtocol(t *testing.T, c *wlturbo.Display) (uint32, uint32) {
27+ t.Helper()
28+ comp := bindProtocol(t, c, "wl_compositor")
29+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
30+ surf := c.AllocateID()
31+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
32+ registerProtocol(t, c, surf)
33+ layer := c.AllocateID()
34+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, uint32(0), uint32(ports.LayerTop), "test")
35+ return surf, layer
36+}
37+func TestLayerInvalidSize(t *testing.T) {
38+ s, _, _, dir := lifecycleServer(t)
39+ c := protocolClient(t, s, dir)
40+ surf, layer := layerProtocol(t, c)
41+ registerProtocol(t, c, layer)
42+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
43+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(ports.AnchorTop))
44+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
45+ expectProtocolError(t, c, layer, uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize))
46+}
47+func TestLayerLifecycle(t *testing.T) {
48+ s, events, _, dir := lifecycleServer(t)
49+ c := protocolClient(t, s, dir)
50+ surf, layer := layerProtocol(t, c)
51+ proxy := &layerProxy{configured: make(chan [3]uint32, 2)}
52+ proxy.SetID(layer)
53+ c.Context().Register(proxy)
54+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
55+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
56+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30))
57+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
58+ if err := c.Roundtrip(); err != nil {
59+ t.Fatal(err)
60+ }
61+ var config [3]uint32
62+ select {
63+ case config = <-proxy.configured:
64+ case <-time.After(2 * time.Second):
65+ t.Fatal("configure timeout")
66+ }
67+ if config[1] != 1920 || config[2] != 30 {
68+ t.Fatalf("configure: %v", config)
69+ }
70+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, config[0])
71+ shm := bindProtocol(t, c, "wl_shm")
72+ registerProtocol(t, c, shm)
73+ fd, err := unix.MemfdCreate("layer-buffer", 0)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ defer unix.Close(fd)
78+ if err = unix.Ftruncate(fd, 1920*30*4); err != nil {
79+ t.Fatal(err)
80+ }
81+ pool, buf := c.AllocateID(), c.AllocateID()
82+ registerProtocol(t, c, pool)
83+ registerProtocol(t, c, buf)
84+ if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil {
85+ t.Fatal(err)
86+ }
87+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888))
88+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
89+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
90+ if err = c.Roundtrip(); err != nil {
91+ t.Fatal(err)
92+ }
93+ select {
94+ case ev := <-events:
95+ v, ok := ev.(ports.LayerChanged)
96+ 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 {
97+ t.Fatalf("mapped: %#v", ev)
98+ }
99+ case <-time.After(2 * time.Second):
100+ t.Fatal("map timeout")
101+ }
102+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
103+ if err = c.Roundtrip(); err != nil {
104+ t.Fatal(err)
105+ }
106+ select {
107+ case ev := <-events:
108+ v, ok := ev.(ports.LayerChanged)
109+ if !ok || len(v.Layers) != 0 {
110+ t.Fatalf("destroy: %#v", ev)
111+ }
112+ case <-time.After(2 * time.Second):
113+ t.Fatal("destroy timeout")
114+ }
115+}
116+func TestWaybarLayer(t *testing.T) {
117+ tool, err := exec.LookPath("waybar")
118+ if err != nil {
119+ t.Skip("waybar missing")
120+ }
121+ s, events, _, dir := keyboardServer(t)
122+ home := t.TempDir()
123+ config := home + "/config.json"
124+ style := home + "/style.css"
125+ if err = os.WriteFile(config, []byte(`{"layer":"top","position":"top","height":30,"modules-left":["clock"]}`), 0600); err != nil {
126+ t.Fatal(err)
127+ }
128+ if err = os.WriteFile(style, nil, 0600); err != nil {
129+ t.Fatal(err)
130+ }
131+ _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home}, "-c", config, "-s", style)
132+ select {
133+ case ev := <-events:
134+ v, ok := ev.(ports.LayerChanged)
135+ if !ok || len(v.Layers) != 1 || v.Layers[0].Height != 30 {
136+ t.Fatalf("waybar event: %#v stderr: %s", ev, output.String())
137+ }
138+ case err := <-done:
139+ t.Fatalf("waybar exited: %v stderr: %s", err, output.String())
140+ case <-time.After(10 * time.Second):
141+ t.Fatalf("waybar timeout stderr: %s", output.String())
142+ }
143+}
144+
145+func TestLayerAlreadyConstructed(t *testing.T) {
146+ for _, scenario := range []string{"role"} {
147+ t.Run(scenario, func(t *testing.T) {
148+ s, _, _, dir := lifecycleServer(t)
149+ c := protocolClient(t, s, dir)
150+ comp := bindProtocol(t, c, "wl_compositor")
151+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
152+ surf := c.AllocateID()
153+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
154+ registerProtocol(t, c, surf)
155+ switch scenario {
156+ case "role":
157+ sub := bindProtocol(t, c, "wl_subcompositor")
158+ parent := c.AllocateID()
159+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, parent)
160+ registerProtocol(t, c, parent)
161+ id := c.AllocateID()
162+ requestProtocol(t, c, sub, wayland.SubcompositorRequestGetSubsurface, id, surf, parent)
163+ }
164+ id := c.AllocateID()
165+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, id, surf, uint32(0), uint32(ports.LayerTop), "test")
166+ expectProtocolError(t, c, shell, uint32(wlrlayershell.ZwlrLayerShellV1ErrorAlreadyConstructed))
167+ })
168+ }
169+}
170+
171+type doneProxy struct {
172+ wlturbo.BaseProxy
173+ done int
174+ opcode uint16
175+}
176+
177+func (p *doneProxy) Dispatch(e *wlturbo.Event) {
178+ if e.Opcode == p.opcode {
179+ p.done++
180+ }
181+}
182+func TestXDGOutputDone(t *testing.T) {
183+ for _, version := range []uint32{1, 2} {
184+ t.Run(fmt.Sprintf("wl_output_v%d", version), func(t *testing.T) {
185+ s, _, _, dir := lifecycleServer(t)
186+ c := protocolClient(t, s, dir)
187+ global, ok := c.Registry().FindGlobal("wl_output")
188+ if !ok {
189+ t.Fatal("missing wl_output")
190+ }
191+ output, err := c.Registry().BindID(global.Name, global.Interface, version)
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ op := &doneProxy{opcode: uint16(wayland.OutputEventDone)}
196+ op.SetID(output)
197+ c.Context().Register(op)
198+ global, ok = c.Registry().FindGlobal("zxdg_output_manager_v1")
199+ if !ok {
200+ t.Fatal("missing xdg output manager")
201+ }
202+ manager, err := c.Registry().BindID(global.Name, global.Interface, 3)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ registerProtocol(t, c, manager)
207+ if err := c.Roundtrip(); err != nil {
208+ t.Fatal(err)
209+ }
210+ op.done = 0
211+ xdg := c.AllocateID()
212+ xp := &doneProxy{opcode: 2}
213+ xp.SetID(xdg)
214+ c.Context().Register(xp)
215+ requestProtocol(t, c, manager, 1, xdg, output)
216+ if err := c.Roundtrip(); err != nil {
217+ t.Fatal(err)
218+ }
219+ if version == 1 && (xp.done != 1 || op.done != 0) || version == 2 && (xp.done != 0 || op.done != 1) {
220+ t.Fatalf("wl_output v%d: xdg done=%d, wl_output done=%d", version, xp.done, op.done)
221+ }
222+ })
223+ }
224+}
@@ -0,0 +1,224 @@
1+package wayland
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/nefertty/internal/ports"
6+ "github.com/bnema/purego-libwayland/protocol/wayland"
7+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
8+ "github.com/bnema/wlturbo"
9+ "golang.org/x/sys/unix"
10+ "os"
11+ "os/exec"
12+ "testing"
13+ "time"
14+)
15+
16+type layerProxy struct {
17+ wlturbo.BaseProxy
18+ configured chan [3]uint32
19+}
20+
21+func (p *layerProxy) Dispatch(e *wlturbo.Event) {
22+ if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventConfigure) {
23+ p.configured <- [3]uint32{e.Uint32(), e.Uint32(), e.Uint32()}
24+ }
25+}
26+func layerProtocol(t *testing.T, c *wlturbo.Display) (uint32, uint32) {
27+ t.Helper()
28+ comp := bindProtocol(t, c, "wl_compositor")
29+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
30+ surf := c.AllocateID()
31+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
32+ registerProtocol(t, c, surf)
33+ layer := c.AllocateID()
34+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, uint32(0), uint32(ports.LayerTop), "test")
35+ return surf, layer
36+}
37+func TestLayerInvalidSize(t *testing.T) {
38+ s, _, _, dir := lifecycleServer(t)
39+ c := protocolClient(t, s, dir)
40+ surf, layer := layerProtocol(t, c)
41+ registerProtocol(t, c, layer)
42+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
43+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(ports.AnchorTop))
44+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
45+ expectProtocolError(t, c, layer, uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize))
46+}
47+func TestLayerLifecycle(t *testing.T) {
48+ s, events, _, dir := lifecycleServer(t)
49+ c := protocolClient(t, s, dir)
50+ surf, layer := layerProtocol(t, c)
51+ proxy := &layerProxy{configured: make(chan [3]uint32, 2)}
52+ proxy.SetID(layer)
53+ c.Context().Register(proxy)
54+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
55+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
56+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30))
57+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
58+ if err := c.Roundtrip(); err != nil {
59+ t.Fatal(err)
60+ }
61+ var config [3]uint32
62+ select {
63+ case config = <-proxy.configured:
64+ case <-time.After(2 * time.Second):
65+ t.Fatal("configure timeout")
66+ }
67+ if config[1] != 1920 || config[2] != 30 {
68+ t.Fatalf("configure: %v", config)
69+ }
70+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, config[0])
71+ shm := bindProtocol(t, c, "wl_shm")
72+ registerProtocol(t, c, shm)
73+ fd, err := unix.MemfdCreate("layer-buffer", 0)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ defer unix.Close(fd)
78+ if err = unix.Ftruncate(fd, 1920*30*4); err != nil {
79+ t.Fatal(err)
80+ }
81+ pool, buf := c.AllocateID(), c.AllocateID()
82+ registerProtocol(t, c, pool)
83+ registerProtocol(t, c, buf)
84+ if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil {
85+ t.Fatal(err)
86+ }
87+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888))
88+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
89+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
90+ if err = c.Roundtrip(); err != nil {
91+ t.Fatal(err)
92+ }
93+ select {
94+ case ev := <-events:
95+ v, ok := ev.(ports.LayerChanged)
96+ 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 {
97+ t.Fatalf("mapped: %#v", ev)
98+ }
99+ case <-time.After(2 * time.Second):
100+ t.Fatal("map timeout")
101+ }
102+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
103+ if err = c.Roundtrip(); err != nil {
104+ t.Fatal(err)
105+ }
106+ select {
107+ case ev := <-events:
108+ v, ok := ev.(ports.LayerChanged)
109+ if !ok || len(v.Layers) != 0 {
110+ t.Fatalf("destroy: %#v", ev)
111+ }
112+ case <-time.After(2 * time.Second):
113+ t.Fatal("destroy timeout")
114+ }
115+}
116+func TestWaybarLayer(t *testing.T) {
117+ tool, err := exec.LookPath("waybar")
118+ if err != nil {
119+ t.Skip("waybar missing")
120+ }
121+ s, events, _, dir := keyboardServer(t)
122+ home := t.TempDir()
123+ config := home + "/config.json"
124+ style := home + "/style.css"
125+ if err = os.WriteFile(config, []byte(`{"layer":"top","position":"top","height":30,"modules-left":["clock"]}`), 0600); err != nil {
126+ t.Fatal(err)
127+ }
128+ if err = os.WriteFile(style, nil, 0600); err != nil {
129+ t.Fatal(err)
130+ }
131+ _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home}, "-c", config, "-s", style)
132+ select {
133+ case ev := <-events:
134+ v, ok := ev.(ports.LayerChanged)
135+ if !ok || len(v.Layers) != 1 || v.Layers[0].Height != 30 {
136+ t.Fatalf("waybar event: %#v stderr: %s", ev, output.String())
137+ }
138+ case err := <-done:
139+ t.Fatalf("waybar exited: %v stderr: %s", err, output.String())
140+ case <-time.After(10 * time.Second):
141+ t.Fatalf("waybar timeout stderr: %s", output.String())
142+ }
143+}
144+
145+func TestLayerAlreadyConstructed(t *testing.T) {
146+ for _, scenario := range []string{"role"} {
147+ t.Run(scenario, func(t *testing.T) {
148+ s, _, _, dir := lifecycleServer(t)
149+ c := protocolClient(t, s, dir)
150+ comp := bindProtocol(t, c, "wl_compositor")
151+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
152+ surf := c.AllocateID()
153+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
154+ registerProtocol(t, c, surf)
155+ switch scenario {
156+ case "role":
157+ sub := bindProtocol(t, c, "wl_subcompositor")
158+ parent := c.AllocateID()
159+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, parent)
160+ registerProtocol(t, c, parent)
161+ id := c.AllocateID()
162+ requestProtocol(t, c, sub, wayland.SubcompositorRequestGetSubsurface, id, surf, parent)
163+ }
164+ id := c.AllocateID()
165+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, id, surf, uint32(0), uint32(ports.LayerTop), "test")
166+ expectProtocolError(t, c, shell, uint32(wlrlayershell.ZwlrLayerShellV1ErrorAlreadyConstructed))
167+ })
168+ }
169+}
170+
171+type doneProxy struct {
172+ wlturbo.BaseProxy
173+ done int
174+ opcode uint16
175+}
176+
177+func (p *doneProxy) Dispatch(e *wlturbo.Event) {
178+ if e.Opcode == p.opcode {
179+ p.done++
180+ }
181+}
182+func TestXDGOutputDone(t *testing.T) {
183+ for _, version := range []uint32{1, 2} {
184+ t.Run(fmt.Sprintf("wl_output_v%d", version), func(t *testing.T) {
185+ s, _, _, dir := lifecycleServer(t)
186+ c := protocolClient(t, s, dir)
187+ global, ok := c.Registry().FindGlobal("wl_output")
188+ if !ok {
189+ t.Fatal("missing wl_output")
190+ }
191+ output, err := c.Registry().BindID(global.Name, global.Interface, version)
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ op := &doneProxy{opcode: uint16(wayland.OutputEventDone)}
196+ op.SetID(output)
197+ c.Context().Register(op)
198+ global, ok = c.Registry().FindGlobal("zxdg_output_manager_v1")
199+ if !ok {
200+ t.Fatal("missing xdg output manager")
201+ }
202+ manager, err := c.Registry().BindID(global.Name, global.Interface, 3)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ registerProtocol(t, c, manager)
207+ if err := c.Roundtrip(); err != nil {
208+ t.Fatal(err)
209+ }
210+ op.done = 0
211+ xdg := c.AllocateID()
212+ xp := &doneProxy{opcode: 2}
213+ xp.SetID(xdg)
214+ c.Context().Register(xp)
215+ requestProtocol(t, c, manager, 1, xdg, output)
216+ if err := c.Roundtrip(); err != nil {
217+ t.Fatal(err)
218+ }
219+ if version == 1 && (xp.done != 1 || op.done != 0) || version == 2 && (xp.done != 0 || op.done != 1) {
220+ t.Fatalf("wl_output v%d: xdg done=%d, wl_output done=%d", version, xp.done, op.done)
221+ }
222+ })
223+ }
224+}
M internal/adapters/wayland/server.go +2 -1
@@ -43,6 +43,7 @@ type Server struct {
4343 serial uint32
4444 ctx context.Context
4545 windows map[ports.WindowID]*window
46+ layers map[ports.WindowID]*layerSurface
4647 nextWindow ports.WindowID
4748 focused ports.WindowID
4849 pointerFocus ports.WindowID
@@ -91,7 +92,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
9192 d.Close()
9293 return nil, err
9394 }
94- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), 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), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
95+ s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), 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), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
9596 if opts.Keymap != "" {
9697 if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {
9798 cleanup()
@@ -43,6 +43,7 @@ type Server struct {
43 serial uint3243 serial uint32
44 ctx context.Context44 ctx context.Context
45 windows map[ports.WindowID]*window45 windows map[ports.WindowID]*window
46+ layers map[ports.WindowID]*layerSurface
46 nextWindow ports.WindowID47 nextWindow ports.WindowID
47 focused ports.WindowID48 focused ports.WindowID
48 pointerFocus ports.WindowID49 pointerFocus ports.WindowID
@@ -91,7 +92,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
91 d.Close()92 d.Close()
92 return nil, err93 return nil, err
93 }94 }
94- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), 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), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}95+ s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), 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), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
95 if opts.Keymap != "" {96 if opts.Keymap != "" {
96 if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {97 if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {
97 cleanup()98 cleanup()
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 || output == nil || output.Version() < 2 {
29+ x.SendDone()
30+ } else {
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 || output == nil || output.Version() < 2 {
29+ x.SendDone()
30+ } else {
31+ output.SendDone()
32+ }
33+}
34+
35+type xdgOutput struct{}
36+
37+func (xdgOutput) Destroy(*xdgoutput.ZxdgOutputV1) {}
M internal/core/core.go +19 -2
@@ -40,6 +40,9 @@ type Core struct {
4040 buttons map[uint32]bool
4141 cursorX, cursorY float64
4242 seq uint64
43+ layers []ports.LayerSurface
44+ placed []ports.SceneLayer
45+ layerChanged bool
4346 }
4447
4548 func keyName(s string) string {
@@ -144,7 +147,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
144147 }
145148 func (c *Core) publish(ctx context.Context) error {
146149 c.seq++
147- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0)}
150+ scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
148151 alive := map[WindowID]bool{}
149152 focus, _ := c.ws.Focused()
150153 for _, p := range c.ws.Layout() {
@@ -205,6 +208,12 @@ func (c *Core) Run(ctx context.Context) error {
205208 continue
206209 }
207210 switch v := ev.(type) {
211+ case ports.LayerChanged:
212+ c.layers = append([]ports.LayerSurface(nil), v.Layers...)
213+ c.layerChanged = true
214+ var usable ports.Rect
215+ c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
216+ c.ws.SetUsable(usable)
208217 case ports.WindowMapped:
209218 c.ws.AddWindow(v.ID)
210219 case ports.WindowUnmapped:
@@ -229,8 +238,16 @@ func (c *Core) Run(ctx context.Context) error {
229238 switch v := ev.(type) {
230239 case ports.OutputMode:
231240 c.ws.SetOutput(v.Width, v.Height)
241+ if c.layerChanged {
242+ var usable ports.Rect
243+ c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
244+ c.ws.SetUsable(usable)
245+ }
232246 case ports.OutputUsable:
233- c.ws.SetUsable(v.Rect)
247+ // Once layer state arrives, its exclusive zones take precedence.
248+ if !c.layerChanged {
249+ c.ws.SetUsable(v.Rect)
250+ }
234251 }
235252 if err := c.publish(ctx); err != nil {
236253 return nil
@@ -40,6 +40,9 @@ type Core struct {
40 buttons map[uint32]bool40 buttons map[uint32]bool
41 cursorX, cursorY float6441 cursorX, cursorY float64
42 seq uint6442 seq uint64
43+ layers []ports.LayerSurface
44+ placed []ports.SceneLayer
45+ layerChanged bool
43 }46 }
44 47
45 func keyName(s string) string {48 func keyName(s string) string {
@@ -144,7 +147,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
144 }147 }
145 func (c *Core) publish(ctx context.Context) error {148 func (c *Core) publish(ctx context.Context) error {
146 c.seq++149 c.seq++
147- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0)}150+ scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
148 alive := map[WindowID]bool{}151 alive := map[WindowID]bool{}
149 focus, _ := c.ws.Focused()152 focus, _ := c.ws.Focused()
150 for _, p := range c.ws.Layout() {153 for _, p := range c.ws.Layout() {
@@ -205,6 +208,12 @@ func (c *Core) Run(ctx context.Context) error {
205 continue208 continue
206 }209 }
207 switch v := ev.(type) {210 switch v := ev.(type) {
211+ case ports.LayerChanged:
212+ c.layers = append([]ports.LayerSurface(nil), v.Layers...)
213+ c.layerChanged = true
214+ var usable ports.Rect
215+ c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
216+ c.ws.SetUsable(usable)
208 case ports.WindowMapped:217 case ports.WindowMapped:
209 c.ws.AddWindow(v.ID)218 c.ws.AddWindow(v.ID)
210 case ports.WindowUnmapped:219 case ports.WindowUnmapped:
@@ -229,8 +238,16 @@ func (c *Core) Run(ctx context.Context) error {
229 switch v := ev.(type) {238 switch v := ev.(type) {
230 case ports.OutputMode:239 case ports.OutputMode:
231 c.ws.SetOutput(v.Width, v.Height)240 c.ws.SetOutput(v.Width, v.Height)
241+ if c.layerChanged {
242+ var usable ports.Rect
243+ c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
244+ c.ws.SetUsable(usable)
245+ }
232 case ports.OutputUsable:246 case ports.OutputUsable:
233- c.ws.SetUsable(v.Rect)247+ // Once layer state arrives, its exclusive zones take precedence.
248+ if !c.layerChanged {
249+ c.ws.SetUsable(v.Rect)
250+ }
234 }251 }
235 if err := c.publish(ctx); err != nil {252 if err := c.publish(ctx); err != nil {
236 return nil253 return nil
A internal/core/layers.go +75 -0
@@ -0,0 +1,75 @@
1+package core
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+// arrangeLayers places exclusive surfaces first, then sorts the resulting scene by layer.
10+func arrangeLayers(outW, outH int, layers []ports.LayerSurface) (placed []ports.SceneLayer, usable ports.Rect) {
11+ full := ports.Rect{W: max(outW, 0), H: max(outH, 0)}
12+ usable = full
13+ for pass := 0; pass < 2; pass++ {
14+ for _, s := range layers {
15+ if (s.ExclusiveZone > 0) != (pass == 0) {
16+ continue
17+ }
18+ bounds := usable
19+ if s.ExclusiveZone == -1 {
20+ bounds = full
21+ }
22+ top, right, bottom, left := int(s.Margin[0]), int(s.Margin[1]), int(s.Margin[2]), int(s.Margin[3])
23+ w, h := s.Width, s.Height
24+ x, y := bounds.X+(bounds.W-w)/2, bounds.Y+(bounds.H-h)/2
25+ switch s.Anchor & (ports.AnchorLeft | ports.AnchorRight) {
26+ case ports.AnchorLeft | ports.AnchorRight:
27+ span := bounds.W - left - right
28+ if w == 0 {
29+ w = span
30+ }
31+ x = bounds.X + left + (span-w)/2
32+ case ports.AnchorLeft:
33+ x = bounds.X + left
34+ case ports.AnchorRight:
35+ x = bounds.X + bounds.W - w - right
36+ }
37+ switch s.Anchor & (ports.AnchorTop | ports.AnchorBottom) {
38+ case ports.AnchorTop | ports.AnchorBottom:
39+ span := bounds.H - top - bottom
40+ if h == 0 {
41+ h = span
42+ }
43+ y = bounds.Y + top + (span-h)/2
44+ case ports.AnchorTop:
45+ y = bounds.Y + top
46+ case ports.AnchorBottom:
47+ y = bounds.Y + bounds.H - h - bottom
48+ }
49+ placed = append(placed, ports.SceneLayer{ID: s.ID, Layer: s.Layer, Rect: ports.Rect{X: x, Y: y, W: w, H: h}})
50+ if s.ExclusiveZone <= 0 {
51+ continue
52+ }
53+ a := s.Anchor
54+ z := int(s.ExclusiveZone)
55+ switch {
56+ case a&ports.AnchorTop != 0 && a&ports.AnchorBottom == 0 && (a&(ports.AnchorLeft|ports.AnchorRight) == 0 || a&(ports.AnchorLeft|ports.AnchorRight) == ports.AnchorLeft|ports.AnchorRight):
57+ z = min(max(z+top, 0), usable.H)
58+ usable.Y += z
59+ usable.H -= z
60+ case a&ports.AnchorBottom != 0 && a&ports.AnchorTop == 0 && (a&(ports.AnchorLeft|ports.AnchorRight) == 0 || a&(ports.AnchorLeft|ports.AnchorRight) == ports.AnchorLeft|ports.AnchorRight):
61+ z = min(max(z+bottom, 0), usable.H)
62+ usable.H -= z
63+ case a&ports.AnchorLeft != 0 && a&ports.AnchorRight == 0 && (a&(ports.AnchorTop|ports.AnchorBottom) == 0 || a&(ports.AnchorTop|ports.AnchorBottom) == ports.AnchorTop|ports.AnchorBottom):
64+ z = min(max(z+left, 0), usable.W)
65+ usable.X += z
66+ usable.W -= z
67+ case a&ports.AnchorRight != 0 && a&ports.AnchorLeft == 0 && (a&(ports.AnchorTop|ports.AnchorBottom) == 0 || a&(ports.AnchorTop|ports.AnchorBottom) == ports.AnchorTop|ports.AnchorBottom):
68+ z = min(max(z+right, 0), usable.W)
69+ usable.W -= z
70+ }
71+ }
72+ }
73+ sort.SliceStable(placed, func(i, j int) bool { return placed[i].Layer < placed[j].Layer })
74+ return
75+}
@@ -0,0 +1,75 @@
1+package core
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+// arrangeLayers places exclusive surfaces first, then sorts the resulting scene by layer.
10+func arrangeLayers(outW, outH int, layers []ports.LayerSurface) (placed []ports.SceneLayer, usable ports.Rect) {
11+ full := ports.Rect{W: max(outW, 0), H: max(outH, 0)}
12+ usable = full
13+ for pass := 0; pass < 2; pass++ {
14+ for _, s := range layers {
15+ if (s.ExclusiveZone > 0) != (pass == 0) {
16+ continue
17+ }
18+ bounds := usable
19+ if s.ExclusiveZone == -1 {
20+ bounds = full
21+ }
22+ top, right, bottom, left := int(s.Margin[0]), int(s.Margin[1]), int(s.Margin[2]), int(s.Margin[3])
23+ w, h := s.Width, s.Height
24+ x, y := bounds.X+(bounds.W-w)/2, bounds.Y+(bounds.H-h)/2
25+ switch s.Anchor & (ports.AnchorLeft | ports.AnchorRight) {
26+ case ports.AnchorLeft | ports.AnchorRight:
27+ span := bounds.W - left - right
28+ if w == 0 {
29+ w = span
30+ }
31+ x = bounds.X + left + (span-w)/2
32+ case ports.AnchorLeft:
33+ x = bounds.X + left
34+ case ports.AnchorRight:
35+ x = bounds.X + bounds.W - w - right
36+ }
37+ switch s.Anchor & (ports.AnchorTop | ports.AnchorBottom) {
38+ case ports.AnchorTop | ports.AnchorBottom:
39+ span := bounds.H - top - bottom
40+ if h == 0 {
41+ h = span
42+ }
43+ y = bounds.Y + top + (span-h)/2
44+ case ports.AnchorTop:
45+ y = bounds.Y + top
46+ case ports.AnchorBottom:
47+ y = bounds.Y + bounds.H - h - bottom
48+ }
49+ placed = append(placed, ports.SceneLayer{ID: s.ID, Layer: s.Layer, Rect: ports.Rect{X: x, Y: y, W: w, H: h}})
50+ if s.ExclusiveZone <= 0 {
51+ continue
52+ }
53+ a := s.Anchor
54+ z := int(s.ExclusiveZone)
55+ switch {
56+ case a&ports.AnchorTop != 0 && a&ports.AnchorBottom == 0 && (a&(ports.AnchorLeft|ports.AnchorRight) == 0 || a&(ports.AnchorLeft|ports.AnchorRight) == ports.AnchorLeft|ports.AnchorRight):
57+ z = min(max(z+top, 0), usable.H)
58+ usable.Y += z
59+ usable.H -= z
60+ case a&ports.AnchorBottom != 0 && a&ports.AnchorTop == 0 && (a&(ports.AnchorLeft|ports.AnchorRight) == 0 || a&(ports.AnchorLeft|ports.AnchorRight) == ports.AnchorLeft|ports.AnchorRight):
61+ z = min(max(z+bottom, 0), usable.H)
62+ usable.H -= z
63+ case a&ports.AnchorLeft != 0 && a&ports.AnchorRight == 0 && (a&(ports.AnchorTop|ports.AnchorBottom) == 0 || a&(ports.AnchorTop|ports.AnchorBottom) == ports.AnchorTop|ports.AnchorBottom):
64+ z = min(max(z+left, 0), usable.W)
65+ usable.X += z
66+ usable.W -= z
67+ case a&ports.AnchorRight != 0 && a&ports.AnchorLeft == 0 && (a&(ports.AnchorTop|ports.AnchorBottom) == 0 || a&(ports.AnchorTop|ports.AnchorBottom) == ports.AnchorTop|ports.AnchorBottom):
68+ z = min(max(z+right, 0), usable.W)
69+ usable.W -= z
70+ }
71+ }
72+ }
73+ sort.SliceStable(placed, func(i, j int) bool { return placed[i].Layer < placed[j].Layer })
74+ return
75+}
A internal/core/layers_test.go +110 -0
@@ -0,0 +1,110 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+func TestArrangeLayers(t *testing.T) {
12+ bar := ports.LayerSurface{ID: 1, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30}
13+ tests := []struct {
14+ name string
15+ layers []ports.LayerSurface
16+ rects []ports.Rect
17+ usable ports.Rect
18+ }{
19+ {"top", []ports.LayerSurface{bar}, []ports.Rect{{W: 1920, H: 30}}, ports.Rect{Y: 30, W: 1920, H: 1050}},
20+ {"bottom margin", []ports.LayerSurface{{Anchor: ports.AnchorBottom, Width: 100, Height: 20, ExclusiveZone: 20, Margin: [4]int32{0, 0, 5, 0}}}, []ports.Rect{{X: 910, Y: 1055, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1055}},
21+ {"left dock", []ports.LayerSurface{{Anchor: ports.AnchorLeft | ports.AnchorTop | ports.AnchorBottom, Width: 40, Height: 1080, ExclusiveZone: 40}}, []ports.Rect{{W: 40, H: 1080}}, ports.Rect{X: 40, W: 1880, H: 1080}},
22+ {"center", []ports.LayerSurface{{Width: 100, Height: 40, Layer: ports.LayerOverlay}}, []ports.Rect{{X: 910, Y: 520, W: 100, H: 40}}, ports.Rect{W: 1920, H: 1080}},
23+ {"opposing horizontal anchors with margins", []ports.LayerSurface{{Anchor: ports.AnchorLeft | ports.AnchorRight, Width: 100, Height: 20, Margin: [4]int32{0, 30, 0, 10}}}, []ports.Rect{{X: 900, Y: 530, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1080}},
24+ {"top only", []ports.LayerSurface{{Anchor: ports.AnchorTop, Width: 100, Height: 20, Margin: [4]int32{7, 0, 0, 0}}}, []ports.Rect{{X: 910, Y: 7, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1080}},
25+ {"full span between margins", []ports.LayerSurface{{Anchor: ports.AnchorLeft | ports.AnchorRight | ports.AnchorTop, Height: 20, Margin: [4]int32{7, 30, 0, 10}}}, []ports.Rect{{X: 10, Y: 7, W: 1880, H: 20}}, ports.Rect{W: 1920, H: 1080}},
26+ {"opposing vertical anchors with margins", []ports.LayerSurface{{Anchor: ports.AnchorTop | ports.AnchorBottom, Width: 100, Height: 20, Margin: [4]int32{10, 0, 30, 0}}}, []ports.Rect{{X: 910, Y: 520, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1080}},
27+ {"minus one", []ports.LayerSurface{bar, {Layer: ports.LayerTop, Anchor: ports.AnchorTop, Width: 100, Height: 20, ExclusiveZone: -1}}, []ports.Rect{{W: 1920, H: 30}, {X: 910, W: 100, H: 20}}, ports.Rect{Y: 30, W: 1920, H: 1050}},
28+ {"stack", []ports.LayerSurface{bar, {Layer: ports.LayerTop, Anchor: bar.Anchor, Width: 1920, Height: 20, ExclusiveZone: 20}}, []ports.Rect{{W: 1920, H: 30}, {Y: 30, W: 1920, H: 20}}, ports.Rect{Y: 50, W: 1920, H: 1030}},
29+ }
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ got, u := arrangeLayers(1920, 1080, tt.layers)
33+ if u != tt.usable || len(got) != len(tt.rects) {
34+ t.Fatalf("got %v %v", got, u)
35+ }
36+ for i, r := range tt.rects {
37+ if got[i].Rect != r {
38+ t.Fatalf("rect %d: %v want %v", i, got[i].Rect, r)
39+ }
40+ }
41+ })
42+ }
43+ layers := []ports.LayerSurface{{ID: 1, Layer: ports.LayerOverlay}, {ID: 2, Layer: ports.LayerBottom}, {ID: 3, Layer: ports.LayerBackground}, {ID: 4, Layer: ports.LayerTop}, {ID: 5, Layer: ports.LayerBottom}}
44+ got, _ := arrangeLayers(100, 100, layers)
45+ for i, id := range []ports.WindowID{3, 2, 5, 4, 1} {
46+ if got[i].ID != id {
47+ t.Fatalf("order: %v", got)
48+ }
49+ }
50+}
51+
52+func TestLayerChangedChannels(t *testing.T) {
53+ client := make(chan ports.ClientEvent)
54+ output := make(chan ports.OutputEvent)
55+ commands := make(chan ports.ClientCommand, 16)
56+ scenes := make(chan ports.Scene, 1)
57+ cfg := ports.Config{}
58+ cfg.Keyboard.CmdKey = "super"
59+ cfg.Layout.DefaultColumnWidth = "1/2"
60+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes})
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ ctx, cancel := context.WithCancel(context.Background())
65+ defer cancel()
66+ done := make(chan struct{})
67+ go func() { defer close(done); _ = c.Run(ctx) }()
68+ recv := func() ports.Scene {
69+ t.Helper()
70+ select {
71+ case s := <-scenes:
72+ return s
73+ case <-time.After(time.Second):
74+ t.Fatal("scene timeout")
75+ return ports.Scene{}
76+ }
77+ }
78+ output <- ports.OutputMode{Width: 1920, Height: 1080}
79+ recv()
80+ client <- ports.WindowMapped{ID: 1}
81+ before := recv()
82+ for len(commands) > 0 {
83+ <-commands
84+ }
85+ bar := ports.LayerSurface{ID: 2, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30}
86+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
87+ scene := recv()
88+ if len(scene.Layers) != 1 || scene.Layers[0].Rect != (ports.Rect{W: 1920, H: 30}) || len(scene.Windows) != 1 || scene.Windows[0].Rect.Y < 30 {
89+ t.Fatalf("scene: %v", scene)
90+ }
91+ if scene.Windows[0].Rect.H != before.Windows[0].Rect.H-30 {
92+ t.Fatalf("height: %v vs %v", scene.Windows, before.Windows)
93+ }
94+ select {
95+ case v := <-commands:
96+ cfg, ok := v.(ports.ConfigureWindow)
97+ if !ok || cfg.ID != 1 || cfg.Height != scene.Windows[0].Rect.H {
98+ t.Fatalf("configure: %v", v)
99+ }
100+ case <-time.After(time.Second):
101+ t.Fatal("configure timeout")
102+ }
103+ scene.Layers[0].Rect.Y = 99
104+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
105+ if next := recv(); next.Layers[0].Rect.Y != 0 {
106+ t.Fatalf("scene alias: %v", next)
107+ }
108+ cancel()
109+ <-done
110+}
@@ -0,0 +1,110 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+func TestArrangeLayers(t *testing.T) {
12+ bar := ports.LayerSurface{ID: 1, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30}
13+ tests := []struct {
14+ name string
15+ layers []ports.LayerSurface
16+ rects []ports.Rect
17+ usable ports.Rect
18+ }{
19+ {"top", []ports.LayerSurface{bar}, []ports.Rect{{W: 1920, H: 30}}, ports.Rect{Y: 30, W: 1920, H: 1050}},
20+ {"bottom margin", []ports.LayerSurface{{Anchor: ports.AnchorBottom, Width: 100, Height: 20, ExclusiveZone: 20, Margin: [4]int32{0, 0, 5, 0}}}, []ports.Rect{{X: 910, Y: 1055, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1055}},
21+ {"left dock", []ports.LayerSurface{{Anchor: ports.AnchorLeft | ports.AnchorTop | ports.AnchorBottom, Width: 40, Height: 1080, ExclusiveZone: 40}}, []ports.Rect{{W: 40, H: 1080}}, ports.Rect{X: 40, W: 1880, H: 1080}},
22+ {"center", []ports.LayerSurface{{Width: 100, Height: 40, Layer: ports.LayerOverlay}}, []ports.Rect{{X: 910, Y: 520, W: 100, H: 40}}, ports.Rect{W: 1920, H: 1080}},
23+ {"opposing horizontal anchors with margins", []ports.LayerSurface{{Anchor: ports.AnchorLeft | ports.AnchorRight, Width: 100, Height: 20, Margin: [4]int32{0, 30, 0, 10}}}, []ports.Rect{{X: 900, Y: 530, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1080}},
24+ {"top only", []ports.LayerSurface{{Anchor: ports.AnchorTop, Width: 100, Height: 20, Margin: [4]int32{7, 0, 0, 0}}}, []ports.Rect{{X: 910, Y: 7, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1080}},
25+ {"full span between margins", []ports.LayerSurface{{Anchor: ports.AnchorLeft | ports.AnchorRight | ports.AnchorTop, Height: 20, Margin: [4]int32{7, 30, 0, 10}}}, []ports.Rect{{X: 10, Y: 7, W: 1880, H: 20}}, ports.Rect{W: 1920, H: 1080}},
26+ {"opposing vertical anchors with margins", []ports.LayerSurface{{Anchor: ports.AnchorTop | ports.AnchorBottom, Width: 100, Height: 20, Margin: [4]int32{10, 0, 30, 0}}}, []ports.Rect{{X: 910, Y: 520, W: 100, H: 20}}, ports.Rect{W: 1920, H: 1080}},
27+ {"minus one", []ports.LayerSurface{bar, {Layer: ports.LayerTop, Anchor: ports.AnchorTop, Width: 100, Height: 20, ExclusiveZone: -1}}, []ports.Rect{{W: 1920, H: 30}, {X: 910, W: 100, H: 20}}, ports.Rect{Y: 30, W: 1920, H: 1050}},
28+ {"stack", []ports.LayerSurface{bar, {Layer: ports.LayerTop, Anchor: bar.Anchor, Width: 1920, Height: 20, ExclusiveZone: 20}}, []ports.Rect{{W: 1920, H: 30}, {Y: 30, W: 1920, H: 20}}, ports.Rect{Y: 50, W: 1920, H: 1030}},
29+ }
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ got, u := arrangeLayers(1920, 1080, tt.layers)
33+ if u != tt.usable || len(got) != len(tt.rects) {
34+ t.Fatalf("got %v %v", got, u)
35+ }
36+ for i, r := range tt.rects {
37+ if got[i].Rect != r {
38+ t.Fatalf("rect %d: %v want %v", i, got[i].Rect, r)
39+ }
40+ }
41+ })
42+ }
43+ layers := []ports.LayerSurface{{ID: 1, Layer: ports.LayerOverlay}, {ID: 2, Layer: ports.LayerBottom}, {ID: 3, Layer: ports.LayerBackground}, {ID: 4, Layer: ports.LayerTop}, {ID: 5, Layer: ports.LayerBottom}}
44+ got, _ := arrangeLayers(100, 100, layers)
45+ for i, id := range []ports.WindowID{3, 2, 5, 4, 1} {
46+ if got[i].ID != id {
47+ t.Fatalf("order: %v", got)
48+ }
49+ }
50+}
51+
52+func TestLayerChangedChannels(t *testing.T) {
53+ client := make(chan ports.ClientEvent)
54+ output := make(chan ports.OutputEvent)
55+ commands := make(chan ports.ClientCommand, 16)
56+ scenes := make(chan ports.Scene, 1)
57+ cfg := ports.Config{}
58+ cfg.Keyboard.CmdKey = "super"
59+ cfg.Layout.DefaultColumnWidth = "1/2"
60+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes})
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ ctx, cancel := context.WithCancel(context.Background())
65+ defer cancel()
66+ done := make(chan struct{})
67+ go func() { defer close(done); _ = c.Run(ctx) }()
68+ recv := func() ports.Scene {
69+ t.Helper()
70+ select {
71+ case s := <-scenes:
72+ return s
73+ case <-time.After(time.Second):
74+ t.Fatal("scene timeout")
75+ return ports.Scene{}
76+ }
77+ }
78+ output <- ports.OutputMode{Width: 1920, Height: 1080}
79+ recv()
80+ client <- ports.WindowMapped{ID: 1}
81+ before := recv()
82+ for len(commands) > 0 {
83+ <-commands
84+ }
85+ bar := ports.LayerSurface{ID: 2, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30}
86+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
87+ scene := recv()
88+ if len(scene.Layers) != 1 || scene.Layers[0].Rect != (ports.Rect{W: 1920, H: 30}) || len(scene.Windows) != 1 || scene.Windows[0].Rect.Y < 30 {
89+ t.Fatalf("scene: %v", scene)
90+ }
91+ if scene.Windows[0].Rect.H != before.Windows[0].Rect.H-30 {
92+ t.Fatalf("height: %v vs %v", scene.Windows, before.Windows)
93+ }
94+ select {
95+ case v := <-commands:
96+ cfg, ok := v.(ports.ConfigureWindow)
97+ if !ok || cfg.ID != 1 || cfg.Height != scene.Windows[0].Rect.H {
98+ t.Fatalf("configure: %v", v)
99+ }
100+ case <-time.After(time.Second):
101+ t.Fatal("configure timeout")
102+ }
103+ scene.Layers[0].Rect.Y = 99
104+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
105+ if next := recv(); next.Layers[0].Rect.Y != 0 {
106+ t.Fatalf("scene alias: %v", next)
107+ }
108+ cancel()
109+ <-done
110+}
M internal/ports/messages.go +47 -0
@@ -24,6 +24,43 @@ type WindowFullscreenRequest struct {
2424
2525 func (WindowFullscreenRequest) clientEvent() {}
2626
27+// Layer is a wlr-layer-shell stacking layer.
28+type Layer uint32
29+
30+const (
31+ LayerBackground Layer = iota
32+ LayerBottom
33+ LayerTop
34+ LayerOverlay
35+)
36+
37+// Anchor edges, as in zwlr_layer_surface_v1.anchor.
38+const (
39+ AnchorTop uint32 = 1
40+ AnchorBottom uint32 = 2
41+ AnchorLeft uint32 = 4
42+ AnchorRight uint32 = 8
43+)
44+
45+// LayerSurface is the committed state of one mapped layer surface. Width and
46+// Height are the committed buffer size. Margin is top, right, bottom, left.
47+// IDs share the WindowID space with windows and never collide.
48+type LayerSurface struct {
49+ ID WindowID
50+ Layer Layer
51+ Anchor uint32
52+ ExclusiveZone int32
53+ Margin [4]int32
54+ Width, Height int
55+ Namespace string
56+}
57+
58+// LayerChanged carries wayland → core the full list of mapped layer surfaces,
59+// sent whenever a layer surface maps, unmaps or commits new layer state.
60+type LayerChanged struct{ Layers []LayerSurface }
61+
62+func (LayerChanged) clientEvent() {}
63+
2764 // Mods carries input → core modifier flags.
2865 type Mods uint8
2966
@@ -151,6 +188,16 @@ type Scene struct {
151188 OutputWidth, OutputHeight int
152189 Background string
153190 Windows []SceneWindow
191+ // Layers are drawn in slice order: background and bottom before windows,
192+ // top and overlay after. A fullscreen window covers bottom and top.
193+ Layers []SceneLayer
194+}
195+
196+// SceneLayer carries core → renderer layer surface placement.
197+type SceneLayer struct {
198+ ID WindowID
199+ Layer Layer
200+ Rect Rect
154201 }
155202
156203 // SurfaceContent carries wayland → output the latest committed pixels of a
@@ -24,6 +24,43 @@ type WindowFullscreenRequest struct {
24 24
25 func (WindowFullscreenRequest) clientEvent() {}25 func (WindowFullscreenRequest) clientEvent() {}
26 26
27+// Layer is a wlr-layer-shell stacking layer.
28+type Layer uint32
29+
30+const (
31+ LayerBackground Layer = iota
32+ LayerBottom
33+ LayerTop
34+ LayerOverlay
35+)
36+
37+// Anchor edges, as in zwlr_layer_surface_v1.anchor.
38+const (
39+ AnchorTop uint32 = 1
40+ AnchorBottom uint32 = 2
41+ AnchorLeft uint32 = 4
42+ AnchorRight uint32 = 8
43+)
44+
45+// LayerSurface is the committed state of one mapped layer surface. Width and
46+// Height are the committed buffer size. Margin is top, right, bottom, left.
47+// IDs share the WindowID space with windows and never collide.
48+type LayerSurface struct {
49+ ID WindowID
50+ Layer Layer
51+ Anchor uint32
52+ ExclusiveZone int32
53+ Margin [4]int32
54+ Width, Height int
55+ Namespace string
56+}
57+
58+// LayerChanged carries wayland → core the full list of mapped layer surfaces,
59+// sent whenever a layer surface maps, unmaps or commits new layer state.
60+type LayerChanged struct{ Layers []LayerSurface }
61+
62+func (LayerChanged) clientEvent() {}
63+
27 // Mods carries input → core modifier flags.64 // Mods carries input → core modifier flags.
28 type Mods uint865 type Mods uint8
29 66
@@ -151,6 +188,16 @@ type Scene struct {
151 OutputWidth, OutputHeight int188 OutputWidth, OutputHeight int
152 Background string189 Background string
153 Windows []SceneWindow190 Windows []SceneWindow
191+ // Layers are drawn in slice order: background and bottom before windows,
192+ // top and overlay after. A fullscreen window covers bottom and top.
193+ Layers []SceneLayer
194+}
195+
196+// SceneLayer carries core → renderer layer surface placement.
197+type SceneLayer struct {
198+ ID WindowID
199+ Layer Layer
200+ Rect Rect
154 }201 }
155 202
156 // SurfaceContent carries wayland → output the latest committed pixels of a203 // SurfaceContent carries wayland → output the latest committed pixels of a

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