detached 73d50d7

feat(wayland): add layer-shell surfaces and event-driven interaction hooks

commit
73d50d7c882067bb0257941ee1ff3dd8d784ec6f
author
brice · · 2026-09-30 09:46 UTC
committer
brice · · 2026-09-30 14:46 UTC
parent
47aaa0f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +3076 -83
M internal/layout/layout.go +26 -1
@@ -105,6 +105,11 @@ type Node struct {
105105 ScrollX, ScrollY float64
106106 NoWrap bool // measure text at unbounded width; useful for single-line editors
107107 EditorScroll bool // clip and clamp own text on both axes, without child scrolling
108+ // Rect, when HasRect is set on a direct child of a Stack, is the child's
109+ // border-box geometry relative to the Stack's content box. It replaces the
110+ // child's authored width, height and margin; it is ignored elsewhere.
111+ Rect Rect
112+ HasRect bool
108113 }
109114
110115 // Result is a detached layout snapshot. Style is deliberately not retained.
@@ -312,6 +317,12 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin
312317 if child == nil || style(child).Display == css.KeywordNone {
313318 continue
314319 }
320+ if n.Kind == Stack && child.HasRect {
321+ size.W = math.Max(size.W, safe(child.Rect.X+child.Rect.W))
322+ size.H = math.Max(size.H, safe(child.Rect.Y+child.Rect.H))
323+ count++
324+ continue
325+ }
315326 cs := style(child)
316327 inW, inH := flexInsets(cs, w)
317328 measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1)
@@ -530,7 +541,18 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
530541 if ch == nil {
531542 continue
532543 }
533- child, err := c.place(ch, r.Content.X, y, r.Content.W, r.Content.H, nil, nil, depth+1)
544+ var child *Result
545+ var err error
546+ absolute := n.Kind == Stack && ch.HasRect
547+ if absolute {
548+ // Direct geometry: forced border-box size, and the origin is offset
549+ // by the resolved margin so the border box lands exactly at Rect.
550+ w, h := safe(ch.Rect.W), safe(ch.Rect.H)
551+ m := edges(style(ch).Margin, r.Content.W)
552+ child, err = c.place(ch, coord(r.Content.X+ch.Rect.X-m.Left), coord(r.Content.Y+ch.Rect.Y-m.Top), r.Content.W, r.Content.H, &w, &h, depth+1)
553+ } else {
554+ child, err = c.place(ch, r.Content.X, y, r.Content.W, r.Content.H, nil, nil, depth+1)
555+ }
534556 if err != nil {
535557 return err
536558 }
@@ -538,6 +560,9 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
538560 continue
539561 }
540562 cw, chh := envelope(child)
563+ if absolute {
564+ cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)
565+ }
541566 r.Children = append(r.Children, child)
542567 r.ContentSize.W = math.Max(r.ContentSize.W, cw)
543568 if n.Kind == Stack {
@@ -105,6 +105,11 @@ type Node struct {
105 ScrollX, ScrollY float64105 ScrollX, ScrollY float64
106 NoWrap bool // measure text at unbounded width; useful for single-line editors106 NoWrap bool // measure text at unbounded width; useful for single-line editors
107 EditorScroll bool // clip and clamp own text on both axes, without child scrolling107 EditorScroll bool // clip and clamp own text on both axes, without child scrolling
108+ // Rect, when HasRect is set on a direct child of a Stack, is the child's
109+ // border-box geometry relative to the Stack's content box. It replaces the
110+ // child's authored width, height and margin; it is ignored elsewhere.
111+ Rect Rect
112+ HasRect bool
108 }113 }
109 114
110 // Result is a detached layout snapshot. Style is deliberately not retained.115 // Result is a detached layout snapshot. Style is deliberately not retained.
@@ -312,6 +317,12 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin
312 if child == nil || style(child).Display == css.KeywordNone {317 if child == nil || style(child).Display == css.KeywordNone {
313 continue318 continue
314 }319 }
320+ if n.Kind == Stack && child.HasRect {
321+ size.W = math.Max(size.W, safe(child.Rect.X+child.Rect.W))
322+ size.H = math.Max(size.H, safe(child.Rect.Y+child.Rect.H))
323+ count++
324+ continue
325+ }
315 cs := style(child)326 cs := style(child)
316 inW, inH := flexInsets(cs, w)327 inW, inH := flexInsets(cs, w)
317 measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1)328 measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1)
@@ -530,7 +541,18 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
530 if ch == nil {541 if ch == nil {
531 continue542 continue
532 }543 }
533- child, err := c.place(ch, r.Content.X, y, r.Content.W, r.Content.H, nil, nil, depth+1)544+ var child *Result
545+ var err error
546+ absolute := n.Kind == Stack && ch.HasRect
547+ if absolute {
548+ // Direct geometry: forced border-box size, and the origin is offset
549+ // by the resolved margin so the border box lands exactly at Rect.
550+ w, h := safe(ch.Rect.W), safe(ch.Rect.H)
551+ m := edges(style(ch).Margin, r.Content.W)
552+ child, err = c.place(ch, coord(r.Content.X+ch.Rect.X-m.Left), coord(r.Content.Y+ch.Rect.Y-m.Top), r.Content.W, r.Content.H, &w, &h, depth+1)
553+ } else {
554+ child, err = c.place(ch, r.Content.X, y, r.Content.W, r.Content.H, nil, nil, depth+1)
555+ }
534 if err != nil {556 if err != nil {
535 return err557 return err
536 }558 }
@@ -538,6 +560,9 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
538 continue560 continue
539 }561 }
540 cw, chh := envelope(child)562 cw, chh := envelope(child)
563+ if absolute {
564+ cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)
565+ }
541 r.Children = append(r.Children, child)566 r.Children = append(r.Children, child)
542 r.ContentSize.W = math.Max(r.ContentSize.W, cw)567 r.ContentSize.W = math.Max(r.ContentSize.W, cw)
543 if n.Kind == Stack {568 if n.Kind == Stack {
M internal/layout/layout_test.go +35 -0
@@ -543,3 +543,38 @@ func TestDirectionalTextAlignment(t *testing.T) {
543543 }
544544 }
545545 }
546+
547+func TestStackAbsoluteRect(t *testing.T) {
548+ margin := css.Style{Display: css.KeywordBlock, Margin: css.LengthSides{Top: px(7), Left: px(9)}, Width: px(500), Height: px(500)}
549+ a := node("a", Box, margin)
550+ a.Rect, a.HasRect = Rect{X: 10.5, Y: 20, W: 30, H: 40}, true
551+ flow := node("flow", Box, css.Style{Display: css.KeywordBlock, Height: px(12)})
552+ stack := node("s", Stack, css.Style{Display: css.KeywordBlock, Padding: css.LengthSides{Top: px(3), Left: px(5)}}, a, flow)
553+ out, err := Layout(node("root", Column, css.Style{Display: css.KeywordBlock}, stack), Options{Width: 200, Height: 100})
554+ if err != nil {
555+ t.Fatal(err)
556+ }
557+ kids := out.Tree.Children[0].Children
558+ // Rect is border-box relative to the stack content box; authored size and margin do not apply.
559+ if got, want := kids[0].Border, (Rect{X: 5 + 10.5, Y: 3 + 20, W: 30, H: 40}); got != want {
560+ t.Fatalf("absolute border %+v want %+v", got, want)
561+ }
562+ // Non-Rect children keep normal stack placement at the content origin.
563+ if got := kids[1].Border; got.X != 5 || got.Y != 3 || got.H != 12 {
564+ t.Fatalf("flow child %+v", got)
565+ }
566+ // The stack's auto size covers the absolute child's extent.
567+ if got := out.Tree.Children[0].ContentSize; got.W < 40.5 || got.H < 60 {
568+ t.Fatalf("content size %+v", got)
569+ }
570+ // HasRect is ignored outside a Stack.
571+ b := node("b", Box, css.Style{Display: css.KeywordBlock, Height: px(9)})
572+ b.Rect, b.HasRect = Rect{X: 50, Y: 50, W: 5, H: 5}, true
573+ out, err = Layout(node("col", Column, css.Style{Display: css.KeywordBlock}, b), Options{Width: 200, Height: 100})
574+ if err != nil {
575+ t.Fatal(err)
576+ }
577+ if got := out.Tree.Children[0].Border; got.X != 0 || got.H != 9 {
578+ t.Fatalf("Rect leaked outside Stack: %+v", got)
579+ }
580+}
@@ -543,3 +543,38 @@ func TestDirectionalTextAlignment(t *testing.T) {
543 }543 }
544 }544 }
545 }545 }
546+
547+func TestStackAbsoluteRect(t *testing.T) {
548+ margin := css.Style{Display: css.KeywordBlock, Margin: css.LengthSides{Top: px(7), Left: px(9)}, Width: px(500), Height: px(500)}
549+ a := node("a", Box, margin)
550+ a.Rect, a.HasRect = Rect{X: 10.5, Y: 20, W: 30, H: 40}, true
551+ flow := node("flow", Box, css.Style{Display: css.KeywordBlock, Height: px(12)})
552+ stack := node("s", Stack, css.Style{Display: css.KeywordBlock, Padding: css.LengthSides{Top: px(3), Left: px(5)}}, a, flow)
553+ out, err := Layout(node("root", Column, css.Style{Display: css.KeywordBlock}, stack), Options{Width: 200, Height: 100})
554+ if err != nil {
555+ t.Fatal(err)
556+ }
557+ kids := out.Tree.Children[0].Children
558+ // Rect is border-box relative to the stack content box; authored size and margin do not apply.
559+ if got, want := kids[0].Border, (Rect{X: 5 + 10.5, Y: 3 + 20, W: 30, H: 40}); got != want {
560+ t.Fatalf("absolute border %+v want %+v", got, want)
561+ }
562+ // Non-Rect children keep normal stack placement at the content origin.
563+ if got := kids[1].Border; got.X != 5 || got.Y != 3 || got.H != 12 {
564+ t.Fatalf("flow child %+v", got)
565+ }
566+ // The stack's auto size covers the absolute child's extent.
567+ if got := out.Tree.Children[0].ContentSize; got.W < 40.5 || got.H < 60 {
568+ t.Fatalf("content size %+v", got)
569+ }
570+ // HasRect is ignored outside a Stack.
571+ b := node("b", Box, css.Style{Display: css.KeywordBlock, Height: px(9)})
572+ b.Rect, b.HasRect = Rect{X: 50, Y: 50, W: 5, H: 5}, true
573+ out, err = Layout(node("col", Column, css.Style{Display: css.KeywordBlock}, b), Options{Width: 200, Height: 100})
574+ if err != nil {
575+ t.Fatal(err)
576+ }
577+ if got := out.Tree.Children[0].Border; got.X != 0 || got.H != 9 {
578+ t.Fatalf("Rect leaked outside Stack: %+v", got)
579+ }
580+}
M internal/platform/wayland/events.go +14 -1
@@ -35,6 +35,8 @@ const (
3535 FeedbackFormats
3636 ClipboardInput
3737 IMEInput
38+ // ConfigureLayer carries a layer-surface extent; zero axes are unchanged.
39+ ConfigureLayer
3840 )
3941
4042 type Event struct {
@@ -116,10 +118,21 @@ func (w *Window) Apply(ev Event) error {
116118 w.Width, w.Height, w.Dirty = ev.Width, ev.Height, true
117119 }
118120 case ConfigureSerial:
119- if err := w.XdgSurface.AckConfigure(ev.Serial); err != nil {
121+ var err error
122+ switch {
123+ case w.LayerSurface != nil:
124+ err = w.LayerSurface.AckConfigure(ev.Serial)
125+ case w.XdgSurface != nil:
126+ err = w.XdgSurface.AckConfigure(ev.Serial)
127+ default:
128+ err = fmt.Errorf("configure without a shell surface")
129+ }
130+ if err != nil {
120131 return err
121132 }
122133 w.Configured, w.FrameReady = true, true
134+ case ConfigureLayer:
135+ w.applyLayerSize(ev.Width, ev.Height)
123136 case PreferredScale:
124137 if ev.Scale == 0 {
125138 return fmt.Errorf("invalid preferred scale zero")
@@ -35,6 +35,8 @@ const (
35 FeedbackFormats35 FeedbackFormats
36 ClipboardInput36 ClipboardInput
37 IMEInput37 IMEInput
38+ // ConfigureLayer carries a layer-surface extent; zero axes are unchanged.
39+ ConfigureLayer
38 )40 )
39 41
40 type Event struct {42 type Event struct {
@@ -116,10 +118,21 @@ func (w *Window) Apply(ev Event) error {
116 w.Width, w.Height, w.Dirty = ev.Width, ev.Height, true118 w.Width, w.Height, w.Dirty = ev.Width, ev.Height, true
117 }119 }
118 case ConfigureSerial:120 case ConfigureSerial:
119- if err := w.XdgSurface.AckConfigure(ev.Serial); err != nil {121+ var err error
122+ switch {
123+ case w.LayerSurface != nil:
124+ err = w.LayerSurface.AckConfigure(ev.Serial)
125+ case w.XdgSurface != nil:
126+ err = w.XdgSurface.AckConfigure(ev.Serial)
127+ default:
128+ err = fmt.Errorf("configure without a shell surface")
129+ }
130+ if err != nil {
120 return err131 return err
121 }132 }
122 w.Configured, w.FrameReady = true, true133 w.Configured, w.FrameReady = true, true
134+ case ConfigureLayer:
135+ w.applyLayerSize(ev.Width, ev.Height)
123 case PreferredScale:136 case PreferredScale:
124 if ev.Scale == 0 {137 if ev.Scale == 0 {
125 return fmt.Errorf("invalid preferred scale zero")138 return fmt.Errorf("invalid preferred scale zero")
M internal/platform/wayland/harness_input_test.go +1 -1
@@ -29,7 +29,7 @@ func TestHarnessInput(t *testing.T) {
2929 t.Fatal(err)
3030 }
3131 root := filepath.Join(t.TempDir(), "artifacts")
32- result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, Input: script, Timeout: 25 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessInputClient$", "-test.v"}})
32+ result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Input: script, Timeout: 25 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessInputClient$", "-test.v"}})
3333 if code != harness.Pass {
3434 t.Fatalf("harness code=%d status=%s error=%s artifacts=%s", code, result.Status, result.Error, root)
3535 }
@@ -29,7 +29,7 @@ func TestHarnessInput(t *testing.T) {
29 t.Fatal(err)29 t.Fatal(err)
30 }30 }
31 root := filepath.Join(t.TempDir(), "artifacts")31 root := filepath.Join(t.TempDir(), "artifacts")
32- result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, Input: script, Timeout: 25 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessInputClient$", "-test.v"}})32+ result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Input: script, Timeout: 25 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessInputClient$", "-test.v"}})
33 if code != harness.Pass {33 if code != harness.Pass {
34 t.Fatalf("harness code=%d status=%s error=%s artifacts=%s", code, result.Status, result.Error, root)34 t.Fatalf("harness code=%d status=%s error=%s artifacts=%s", code, result.Status, result.Error, root)
35 }35 }
A internal/platform/wayland/layer.go +402 -0
@@ -0,0 +1,402 @@
1+//go:build linux
2+
3+package wayland
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "math"
9+ "slices"
10+ "sort"
11+ "strings"
12+
13+ "github.com/bnema/wlturbo"
14+ "github.com/bnema/wlturbo/protocol/core"
15+ "github.com/bnema/wlturbo/wl"
16+
17+ "github.com/bnema/nefergui/internal/platform/wayland/layershell"
18+)
19+
20+// zwlr_layer_shell_v1 layers. The values are the wire values.
21+const (
22+ LayerBackground uint32 = 0
23+ LayerBottom uint32 = 1
24+ LayerTop uint32 = 2
25+ LayerOverlay uint32 = 3
26+)
27+
28+// zwlr_layer_surface_v1 anchor bits. They may be OR-ed together.
29+const (
30+ AnchorTop uint32 = 1
31+ AnchorBottom uint32 = 2
32+ AnchorLeft uint32 = 4
33+ AnchorRight uint32 = 8
34+
35+ anchorAll = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
36+)
37+
38+// zwlr_layer_surface_v1 keyboard interactivity modes.
39+const (
40+ KeyboardNone uint32 = 0
41+ KeyboardExclusive uint32 = 1
42+ // KeyboardOnDemand needs zwlr_layer_shell_v1 version 4.
43+ KeyboardOnDemand uint32 = 2
44+)
45+
46+const (
47+ layerShellSupported = 4
48+ outputNameVersion = 4 // wl_output.name was added in version 4
49+ // MaxLayerDimension bounds a compositor-assigned layer surface extent.
50+ MaxLayerDimension = 16384
51+ // DefaultLayerNamespace is used when LayerOptions.Namespace is empty. The
52+ // namespace is an opaque, generic role hint; nothing privileged depends on it.
53+ DefaultLayerNamespace = "nefergui"
54+)
55+
56+// Rect is an integer rectangle in surface-local logical coordinates.
57+type Rect struct{ X, Y, W, H int32 }
58+
59+// LayerOptions selects wlr-layer-shell instead of xdg-toplevel.
60+type LayerOptions struct {
61+ // Output is the exact wl_output name (for example "DP-1"). Empty lets the
62+ // compositor choose its preferred output. A named output that is absent
63+ // fails the connection; a named output removed later closes the window.
64+ Output string
65+ // Namespace is the layer surface namespace; empty selects DefaultLayerNamespace.
66+ Namespace string
67+ // Anchor is a bit set of Anchor* values.
68+ Anchor uint32
69+ // Layer is one of the Layer* values.
70+ Layer uint32
71+ // Keyboard is one of the Keyboard* values.
72+ Keyboard uint32
73+ // ExclusiveZone follows zwlr_layer_surface_v1.set_exclusive_zone
74+ // (positive reserves, 0 is neutral, -1 ignores other zones).
75+ ExclusiveZone int32
76+ // Margin is ordered top, right, bottom, left, like the protocol request.
77+ Margin [4]int32
78+}
79+
80+// SurfaceOptions configures the role and input region of a new Window. The zero
81+// value selects the default xdg-toplevel with full-surface input.
82+type SurfaceOptions struct {
83+ // Layer, when non-nil, creates a layer surface and binds no xdg shell.
84+ Layer *LayerOptions
85+ // InputRects is the initial input region in surface-local logical pixels.
86+ // nil means the whole surface (the protocol default); a non-nil empty slice
87+ // means no input (pointer and touch pass through).
88+ InputRects []Rect
89+}
90+
91+// Validate reports the first invalid option.
92+func (o SurfaceOptions) Validate() error {
93+ if o.Layer != nil {
94+ if err := o.Layer.Validate(); err != nil {
95+ return err
96+ }
97+ }
98+ return validateRects(o.InputRects)
99+}
100+
101+// Validate reports the first invalid layer option.
102+func (o LayerOptions) Validate() error {
103+ switch {
104+ case o.Layer > LayerOverlay:
105+ return fmt.Errorf("invalid layer %d", o.Layer)
106+ case o.Anchor&^anchorAll != 0:
107+ return fmt.Errorf("invalid anchor bits %#x", o.Anchor)
108+ case o.ExclusiveZone < -1:
109+ return fmt.Errorf("invalid exclusive zone %d", o.ExclusiveZone)
110+ case o.Keyboard > KeyboardOnDemand:
111+ return fmt.Errorf("invalid keyboard interactivity %d", o.Keyboard)
112+ case strings.ContainsRune(o.Namespace, 0):
113+ return errors.New("layer namespace contains NUL")
114+ case strings.ContainsRune(o.Output, 0):
115+ return errors.New("layer output contains NUL")
116+ }
117+ return nil
118+}
119+
120+func validateRects(rects []Rect) error {
121+ for i, r := range rects {
122+ if r.W <= 0 || r.H <= 0 {
123+ return fmt.Errorf("input rect %d has non-positive size %dx%d", i, r.W, r.H)
124+ }
125+ if int64(r.X)+int64(r.W) > math.MaxInt32 || int64(r.Y)+int64(r.H) > math.MaxInt32 {
126+ return fmt.Errorf("input rect %d overflows", i)
127+ }
128+ }
129+ return nil
130+}
131+
132+// layerConfigureSize validates a configure extent. Zero leaves that axis unchanged.
133+func layerConfigureSize(width, height uint32) (int32, int32, error) {
134+ if width > MaxLayerDimension || height > MaxLayerDimension {
135+ return 0, 0, fmt.Errorf("layer configure size %dx%d exceeds %d", width, height, MaxLayerDimension)
136+ }
137+ return int32(width), int32(height), nil
138+}
139+
140+// SetTitle sets the toplevel title. Layer surfaces have no title, so it is a no-op.
141+func (w *Window) SetTitle(title string) error {
142+ if w == nil || w.Toplevel == nil {
143+ return nil
144+ }
145+ return w.Toplevel.SetTitle(title)
146+}
147+
148+// IsLayer reports whether the window is a layer surface.
149+func (w *Window) IsLayer() bool { return w != nil && w.LayerSurface != nil }
150+
151+// StageInputRects validates rects and stages them as the pending input region
152+// without committing; the next commit (normally Present) applies them. nil
153+// restores the full surface and a non-nil empty slice makes the surface pass
154+// all input through. A request identical to the current one sends nothing and
155+// allocates nothing. Call from the session owner loop only.
156+func (w *Window) StageInputRects(rects []Rect) error {
157+ _, err := w.stageInputRects(rects)
158+ return err
159+}
160+
161+// SetInputRects stages rects like StageInputRects and, only if they changed and
162+// the window is configured, commits immediately. Before the first configure
163+// the initial commit carries the region.
164+func (w *Window) SetInputRects(rects []Rect) error {
165+ changed, err := w.stageInputRects(rects)
166+ if err != nil || !changed || !w.Configured {
167+ return err
168+ }
169+ return w.Surface.Commit()
170+}
171+
172+func (w *Window) stageInputRects(rects []Rect) (changed bool, err error) {
173+ if w == nil || w.Surface == nil || w.Compositor == nil {
174+ return false, errors.New("window has no surface")
175+ }
176+ if err = validateRects(rects); err != nil {
177+ return false, err
178+ }
179+ if (rects != nil) == w.inputCustom && slices.Equal(rects, w.inputRects) {
180+ return false, nil
181+ }
182+ if err = w.stageInputRegion(rects); err != nil {
183+ return false, err
184+ }
185+ w.inputCustom = rects != nil
186+ w.inputRects = append(w.inputRects[:0], rects...)
187+ return true, nil
188+}
189+
190+func (w *Window) stageInputRegion(rects []Rect) error {
191+ if rects == nil {
192+ return w.Surface.SetInputRegion(nil)
193+ }
194+ region, err := w.Compositor.CreateRegion()
195+ if err != nil {
196+ return err
197+ }
198+ for _, r := range rects {
199+ if err = region.Add(r.X, r.Y, r.W, r.H); err != nil {
200+ break
201+ }
202+ }
203+ if err == nil {
204+ err = w.Surface.SetInputRegion(region)
205+ }
206+ _ = region.Destroy()
207+ return err
208+}
209+
210+// applyLayerSize applies a validated layer configure; zero axes are unchanged.
211+func (w *Window) applyLayerSize(width, height int32) {
212+ if width > 0 && w.Width != width {
213+ w.Width, w.Dirty = width, true
214+ }
215+ if height > 0 && w.Height != height {
216+ w.Height, w.Dirty = height, true
217+ }
218+}
219+
220+// bindLayerShell binds zwlr_layer_shell_v1 (up to version 4).
221+func (w *Window) bindLayerShell(bind func(string, uint32, uint32, wl.Proxy) error) error {
222+ ls := layershell.NewLayerShell(w.Display.Context())
223+ if err := bind(layershell.LayerShellInterface, layerShellSupported, 1, ls); err != nil {
224+ w.Display.Context().Unregister(ls) // never bound: no destructor to send
225+ return err
226+ }
227+ w.LayerShell = ls
228+ return nil
229+}
230+
231+type outputCandidate struct {
232+ out *core.Output
233+ global uint32
234+ name string
235+}
236+
237+// releaseOutputs releases every candidate except keep.
238+func releaseOutputs(cands []*outputCandidate, keep *outputCandidate) {
239+ for _, c := range cands {
240+ if c != keep {
241+ _ = c.out.Release()
242+ }
243+ }
244+}
245+
246+// selectOutput binds every wl_output that can report a name and returns the
247+// global name of the exact match. Unmatched outputs are released.
248+func (w *Window) selectOutput(name string) (*core.Output, uint32, error) {
249+ reg := w.Display.Registry()
250+ var globals []wl.Global
251+ legacy := 0 // outputs older than v4 cannot report a name
252+ for _, g := range reg.GetGlobals() {
253+ if g.Interface != core.OutputInterface {
254+ continue
255+ }
256+ if g.Version >= outputNameVersion {
257+ globals = append(globals, g)
258+ } else {
259+ legacy++
260+ }
261+ }
262+ sort.Slice(globals, func(i, j int) bool { return globals[i].Name < globals[j].Name })
263+ cands := make([]*outputCandidate, 0, len(globals))
264+ ctx := w.Display.Context()
265+ for _, g := range globals {
266+ c := &outputCandidate{out: core.NewOutput(ctx), global: g.Name}
267+ c.out.OnName(func(n string) { c.name = n })
268+ if err := reg.Bind(g.Name, core.OutputInterface, outputNameVersion, c.out); err != nil {
269+ ctx.Unregister(c.out)
270+ releaseOutputs(cands, nil)
271+ return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: err}
272+ }
273+ cands = append(cands, c)
274+ }
275+ if len(cands) > 0 {
276+ if err := w.Display.Roundtrip(); err != nil {
277+ releaseOutputs(cands, nil)
278+ return nil, 0, err
279+ }
280+ }
281+ var chosen *outputCandidate
282+ for _, c := range cands {
283+ if c.name == name {
284+ chosen = c
285+ break
286+ }
287+ }
288+ releaseOutputs(cands, chosen)
289+ if chosen == nil {
290+ cause := fmt.Errorf("output %q not found", name)
291+ if legacy > 0 {
292+ cause = fmt.Errorf("output %q not found; %d wl_output global(s) older than version %d cannot report names", name, legacy, outputNameVersion)
293+ }
294+ return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: cause}
295+ }
296+ return chosen.out, chosen.global, nil
297+}
298+
299+// createLayerSurface creates the role object and stages all double-buffered
300+// state. The caller performs the initial empty commit.
301+func (w *Window) createLayerSurface(o LayerOptions) error {
302+ if o.Keyboard == KeyboardOnDemand && w.LayerShell.Version() < 4 {
303+ return &CapabilityError{Name: layershell.LayerShellInterface, Cause: fmt.Errorf("on-demand keyboard requires version 4, compositor negotiated %d", w.LayerShell.Version())}
304+ }
305+ var out *core.Output
306+ if o.Output != "" {
307+ var global uint32
308+ var err error
309+ if out, global, err = w.selectOutput(o.Output); err != nil {
310+ return err
311+ }
312+ w.layerOutputGlobal, w.layerOutputSelected = global, true
313+ w.Display.Registry().AddGlobalRemoveHandler(outputRemoved{w})
314+ }
315+ ns := o.Namespace
316+ if ns == "" {
317+ ns = DefaultLayerNamespace
318+ }
319+ ls, err := w.LayerShell.GetLayerSurface(w.Surface, out, o.Layer, ns)
320+ if out != nil {
321+ // The compositor keeps its own reference to the output.
322+ _ = out.Release()
323+ }
324+ if err != nil {
325+ return err
326+ }
327+ w.LayerSurface = ls
328+ ls.OnClosed(func() { w.postClosed() })
329+ ls.OnConfigure(func(serial, width, height uint32) {
330+ cw, ch, e := layerConfigureSize(width, height)
331+ if e != nil {
332+ w.feedbackFailure(e)
333+ return
334+ }
335+ if w.readerStarted.Load() {
336+ w.post(Event{Kind: ConfigureLayer, Width: cw, Height: ch})
337+ w.post(Event{Kind: ConfigureSerial, Serial: serial})
338+ return
339+ }
340+ w.applyLayerSize(cw, ch)
341+ if e = ls.AckConfigure(serial); e != nil {
342+ w.feedbackFailure(e)
343+ return
344+ }
345+ w.Configured, w.FrameReady = true, true
346+ })
347+ // Opposite anchors let the compositor assign that axis (size 0).
348+ reqW, reqH := uint32(w.Width), uint32(w.Height)
349+ if o.Anchor&(AnchorLeft|AnchorRight) == AnchorLeft|AnchorRight {
350+ reqW = 0
351+ }
352+ if o.Anchor&(AnchorTop|AnchorBottom) == AnchorTop|AnchorBottom {
353+ reqH = 0
354+ }
355+ if err = ls.SetSize(reqW, reqH); err != nil {
356+ return err
357+ }
358+ if err = ls.SetAnchor(o.Anchor); err != nil {
359+ return err
360+ }
361+ if err = ls.SetExclusiveZone(o.ExclusiveZone); err != nil {
362+ return err
363+ }
364+ if err = ls.SetMargin(o.Margin[0], o.Margin[1], o.Margin[2], o.Margin[3]); err != nil {
365+ return err
366+ }
367+ return ls.SetKeyboardInteractivity(o.Keyboard)
368+}
369+
370+// outputRemoved closes the window when its selected output disappears.
371+type outputRemoved struct{ w *Window }
372+
373+func (h outputRemoved) HandleRegistryGlobalRemove(ev wlturbo.RegistryGlobalRemoveEvent) {
374+ if h.w.layerOutputSelected && ev.Name == h.w.layerOutputGlobal {
375+ h.w.postClosed()
376+ }
377+}
378+
379+func (w *Window) postClosed() {
380+ if w.readerStarted.Load() {
381+ w.post(Event{Kind: CloseEvent})
382+ return
383+ }
384+ w.Closed = true
385+}
386+
387+// closeLayer destroys the layer role and its factory; the wl_surface goes last.
388+func (w *Window) closeLayer() {
389+ if w.LayerSurface != nil {
390+ _ = w.LayerSurface.Destroy()
391+ w.LayerSurface = nil
392+ }
393+ if w.LayerShell != nil {
394+ // zwlr_layer_shell_v1.destroy needs version 3; otherwise forget it locally.
395+ if w.LayerShell.Destroy() != nil {
396+ if c := w.LayerShell.Context(); c != nil {
397+ c.Unregister(w.LayerShell)
398+ }
399+ }
400+ w.LayerShell = nil
401+ }
402+}
@@ -0,0 +1,402 @@
1+//go:build linux
2+
3+package wayland
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "math"
9+ "slices"
10+ "sort"
11+ "strings"
12+
13+ "github.com/bnema/wlturbo"
14+ "github.com/bnema/wlturbo/protocol/core"
15+ "github.com/bnema/wlturbo/wl"
16+
17+ "github.com/bnema/nefergui/internal/platform/wayland/layershell"
18+)
19+
20+// zwlr_layer_shell_v1 layers. The values are the wire values.
21+const (
22+ LayerBackground uint32 = 0
23+ LayerBottom uint32 = 1
24+ LayerTop uint32 = 2
25+ LayerOverlay uint32 = 3
26+)
27+
28+// zwlr_layer_surface_v1 anchor bits. They may be OR-ed together.
29+const (
30+ AnchorTop uint32 = 1
31+ AnchorBottom uint32 = 2
32+ AnchorLeft uint32 = 4
33+ AnchorRight uint32 = 8
34+
35+ anchorAll = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
36+)
37+
38+// zwlr_layer_surface_v1 keyboard interactivity modes.
39+const (
40+ KeyboardNone uint32 = 0
41+ KeyboardExclusive uint32 = 1
42+ // KeyboardOnDemand needs zwlr_layer_shell_v1 version 4.
43+ KeyboardOnDemand uint32 = 2
44+)
45+
46+const (
47+ layerShellSupported = 4
48+ outputNameVersion = 4 // wl_output.name was added in version 4
49+ // MaxLayerDimension bounds a compositor-assigned layer surface extent.
50+ MaxLayerDimension = 16384
51+ // DefaultLayerNamespace is used when LayerOptions.Namespace is empty. The
52+ // namespace is an opaque, generic role hint; nothing privileged depends on it.
53+ DefaultLayerNamespace = "nefergui"
54+)
55+
56+// Rect is an integer rectangle in surface-local logical coordinates.
57+type Rect struct{ X, Y, W, H int32 }
58+
59+// LayerOptions selects wlr-layer-shell instead of xdg-toplevel.
60+type LayerOptions struct {
61+ // Output is the exact wl_output name (for example "DP-1"). Empty lets the
62+ // compositor choose its preferred output. A named output that is absent
63+ // fails the connection; a named output removed later closes the window.
64+ Output string
65+ // Namespace is the layer surface namespace; empty selects DefaultLayerNamespace.
66+ Namespace string
67+ // Anchor is a bit set of Anchor* values.
68+ Anchor uint32
69+ // Layer is one of the Layer* values.
70+ Layer uint32
71+ // Keyboard is one of the Keyboard* values.
72+ Keyboard uint32
73+ // ExclusiveZone follows zwlr_layer_surface_v1.set_exclusive_zone
74+ // (positive reserves, 0 is neutral, -1 ignores other zones).
75+ ExclusiveZone int32
76+ // Margin is ordered top, right, bottom, left, like the protocol request.
77+ Margin [4]int32
78+}
79+
80+// SurfaceOptions configures the role and input region of a new Window. The zero
81+// value selects the default xdg-toplevel with full-surface input.
82+type SurfaceOptions struct {
83+ // Layer, when non-nil, creates a layer surface and binds no xdg shell.
84+ Layer *LayerOptions
85+ // InputRects is the initial input region in surface-local logical pixels.
86+ // nil means the whole surface (the protocol default); a non-nil empty slice
87+ // means no input (pointer and touch pass through).
88+ InputRects []Rect
89+}
90+
91+// Validate reports the first invalid option.
92+func (o SurfaceOptions) Validate() error {
93+ if o.Layer != nil {
94+ if err := o.Layer.Validate(); err != nil {
95+ return err
96+ }
97+ }
98+ return validateRects(o.InputRects)
99+}
100+
101+// Validate reports the first invalid layer option.
102+func (o LayerOptions) Validate() error {
103+ switch {
104+ case o.Layer > LayerOverlay:
105+ return fmt.Errorf("invalid layer %d", o.Layer)
106+ case o.Anchor&^anchorAll != 0:
107+ return fmt.Errorf("invalid anchor bits %#x", o.Anchor)
108+ case o.ExclusiveZone < -1:
109+ return fmt.Errorf("invalid exclusive zone %d", o.ExclusiveZone)
110+ case o.Keyboard > KeyboardOnDemand:
111+ return fmt.Errorf("invalid keyboard interactivity %d", o.Keyboard)
112+ case strings.ContainsRune(o.Namespace, 0):
113+ return errors.New("layer namespace contains NUL")
114+ case strings.ContainsRune(o.Output, 0):
115+ return errors.New("layer output contains NUL")
116+ }
117+ return nil
118+}
119+
120+func validateRects(rects []Rect) error {
121+ for i, r := range rects {
122+ if r.W <= 0 || r.H <= 0 {
123+ return fmt.Errorf("input rect %d has non-positive size %dx%d", i, r.W, r.H)
124+ }
125+ if int64(r.X)+int64(r.W) > math.MaxInt32 || int64(r.Y)+int64(r.H) > math.MaxInt32 {
126+ return fmt.Errorf("input rect %d overflows", i)
127+ }
128+ }
129+ return nil
130+}
131+
132+// layerConfigureSize validates a configure extent. Zero leaves that axis unchanged.
133+func layerConfigureSize(width, height uint32) (int32, int32, error) {
134+ if width > MaxLayerDimension || height > MaxLayerDimension {
135+ return 0, 0, fmt.Errorf("layer configure size %dx%d exceeds %d", width, height, MaxLayerDimension)
136+ }
137+ return int32(width), int32(height), nil
138+}
139+
140+// SetTitle sets the toplevel title. Layer surfaces have no title, so it is a no-op.
141+func (w *Window) SetTitle(title string) error {
142+ if w == nil || w.Toplevel == nil {
143+ return nil
144+ }
145+ return w.Toplevel.SetTitle(title)
146+}
147+
148+// IsLayer reports whether the window is a layer surface.
149+func (w *Window) IsLayer() bool { return w != nil && w.LayerSurface != nil }
150+
151+// StageInputRects validates rects and stages them as the pending input region
152+// without committing; the next commit (normally Present) applies them. nil
153+// restores the full surface and a non-nil empty slice makes the surface pass
154+// all input through. A request identical to the current one sends nothing and
155+// allocates nothing. Call from the session owner loop only.
156+func (w *Window) StageInputRects(rects []Rect) error {
157+ _, err := w.stageInputRects(rects)
158+ return err
159+}
160+
161+// SetInputRects stages rects like StageInputRects and, only if they changed and
162+// the window is configured, commits immediately. Before the first configure
163+// the initial commit carries the region.
164+func (w *Window) SetInputRects(rects []Rect) error {
165+ changed, err := w.stageInputRects(rects)
166+ if err != nil || !changed || !w.Configured {
167+ return err
168+ }
169+ return w.Surface.Commit()
170+}
171+
172+func (w *Window) stageInputRects(rects []Rect) (changed bool, err error) {
173+ if w == nil || w.Surface == nil || w.Compositor == nil {
174+ return false, errors.New("window has no surface")
175+ }
176+ if err = validateRects(rects); err != nil {
177+ return false, err
178+ }
179+ if (rects != nil) == w.inputCustom && slices.Equal(rects, w.inputRects) {
180+ return false, nil
181+ }
182+ if err = w.stageInputRegion(rects); err != nil {
183+ return false, err
184+ }
185+ w.inputCustom = rects != nil
186+ w.inputRects = append(w.inputRects[:0], rects...)
187+ return true, nil
188+}
189+
190+func (w *Window) stageInputRegion(rects []Rect) error {
191+ if rects == nil {
192+ return w.Surface.SetInputRegion(nil)
193+ }
194+ region, err := w.Compositor.CreateRegion()
195+ if err != nil {
196+ return err
197+ }
198+ for _, r := range rects {
199+ if err = region.Add(r.X, r.Y, r.W, r.H); err != nil {
200+ break
201+ }
202+ }
203+ if err == nil {
204+ err = w.Surface.SetInputRegion(region)
205+ }
206+ _ = region.Destroy()
207+ return err
208+}
209+
210+// applyLayerSize applies a validated layer configure; zero axes are unchanged.
211+func (w *Window) applyLayerSize(width, height int32) {
212+ if width > 0 && w.Width != width {
213+ w.Width, w.Dirty = width, true
214+ }
215+ if height > 0 && w.Height != height {
216+ w.Height, w.Dirty = height, true
217+ }
218+}
219+
220+// bindLayerShell binds zwlr_layer_shell_v1 (up to version 4).
221+func (w *Window) bindLayerShell(bind func(string, uint32, uint32, wl.Proxy) error) error {
222+ ls := layershell.NewLayerShell(w.Display.Context())
223+ if err := bind(layershell.LayerShellInterface, layerShellSupported, 1, ls); err != nil {
224+ w.Display.Context().Unregister(ls) // never bound: no destructor to send
225+ return err
226+ }
227+ w.LayerShell = ls
228+ return nil
229+}
230+
231+type outputCandidate struct {
232+ out *core.Output
233+ global uint32
234+ name string
235+}
236+
237+// releaseOutputs releases every candidate except keep.
238+func releaseOutputs(cands []*outputCandidate, keep *outputCandidate) {
239+ for _, c := range cands {
240+ if c != keep {
241+ _ = c.out.Release()
242+ }
243+ }
244+}
245+
246+// selectOutput binds every wl_output that can report a name and returns the
247+// global name of the exact match. Unmatched outputs are released.
248+func (w *Window) selectOutput(name string) (*core.Output, uint32, error) {
249+ reg := w.Display.Registry()
250+ var globals []wl.Global
251+ legacy := 0 // outputs older than v4 cannot report a name
252+ for _, g := range reg.GetGlobals() {
253+ if g.Interface != core.OutputInterface {
254+ continue
255+ }
256+ if g.Version >= outputNameVersion {
257+ globals = append(globals, g)
258+ } else {
259+ legacy++
260+ }
261+ }
262+ sort.Slice(globals, func(i, j int) bool { return globals[i].Name < globals[j].Name })
263+ cands := make([]*outputCandidate, 0, len(globals))
264+ ctx := w.Display.Context()
265+ for _, g := range globals {
266+ c := &outputCandidate{out: core.NewOutput(ctx), global: g.Name}
267+ c.out.OnName(func(n string) { c.name = n })
268+ if err := reg.Bind(g.Name, core.OutputInterface, outputNameVersion, c.out); err != nil {
269+ ctx.Unregister(c.out)
270+ releaseOutputs(cands, nil)
271+ return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: err}
272+ }
273+ cands = append(cands, c)
274+ }
275+ if len(cands) > 0 {
276+ if err := w.Display.Roundtrip(); err != nil {
277+ releaseOutputs(cands, nil)
278+ return nil, 0, err
279+ }
280+ }
281+ var chosen *outputCandidate
282+ for _, c := range cands {
283+ if c.name == name {
284+ chosen = c
285+ break
286+ }
287+ }
288+ releaseOutputs(cands, chosen)
289+ if chosen == nil {
290+ cause := fmt.Errorf("output %q not found", name)
291+ if legacy > 0 {
292+ cause = fmt.Errorf("output %q not found; %d wl_output global(s) older than version %d cannot report names", name, legacy, outputNameVersion)
293+ }
294+ return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: cause}
295+ }
296+ return chosen.out, chosen.global, nil
297+}
298+
299+// createLayerSurface creates the role object and stages all double-buffered
300+// state. The caller performs the initial empty commit.
301+func (w *Window) createLayerSurface(o LayerOptions) error {
302+ if o.Keyboard == KeyboardOnDemand && w.LayerShell.Version() < 4 {
303+ return &CapabilityError{Name: layershell.LayerShellInterface, Cause: fmt.Errorf("on-demand keyboard requires version 4, compositor negotiated %d", w.LayerShell.Version())}
304+ }
305+ var out *core.Output
306+ if o.Output != "" {
307+ var global uint32
308+ var err error
309+ if out, global, err = w.selectOutput(o.Output); err != nil {
310+ return err
311+ }
312+ w.layerOutputGlobal, w.layerOutputSelected = global, true
313+ w.Display.Registry().AddGlobalRemoveHandler(outputRemoved{w})
314+ }
315+ ns := o.Namespace
316+ if ns == "" {
317+ ns = DefaultLayerNamespace
318+ }
319+ ls, err := w.LayerShell.GetLayerSurface(w.Surface, out, o.Layer, ns)
320+ if out != nil {
321+ // The compositor keeps its own reference to the output.
322+ _ = out.Release()
323+ }
324+ if err != nil {
325+ return err
326+ }
327+ w.LayerSurface = ls
328+ ls.OnClosed(func() { w.postClosed() })
329+ ls.OnConfigure(func(serial, width, height uint32) {
330+ cw, ch, e := layerConfigureSize(width, height)
331+ if e != nil {
332+ w.feedbackFailure(e)
333+ return
334+ }
335+ if w.readerStarted.Load() {
336+ w.post(Event{Kind: ConfigureLayer, Width: cw, Height: ch})
337+ w.post(Event{Kind: ConfigureSerial, Serial: serial})
338+ return
339+ }
340+ w.applyLayerSize(cw, ch)
341+ if e = ls.AckConfigure(serial); e != nil {
342+ w.feedbackFailure(e)
343+ return
344+ }
345+ w.Configured, w.FrameReady = true, true
346+ })
347+ // Opposite anchors let the compositor assign that axis (size 0).
348+ reqW, reqH := uint32(w.Width), uint32(w.Height)
349+ if o.Anchor&(AnchorLeft|AnchorRight) == AnchorLeft|AnchorRight {
350+ reqW = 0
351+ }
352+ if o.Anchor&(AnchorTop|AnchorBottom) == AnchorTop|AnchorBottom {
353+ reqH = 0
354+ }
355+ if err = ls.SetSize(reqW, reqH); err != nil {
356+ return err
357+ }
358+ if err = ls.SetAnchor(o.Anchor); err != nil {
359+ return err
360+ }
361+ if err = ls.SetExclusiveZone(o.ExclusiveZone); err != nil {
362+ return err
363+ }
364+ if err = ls.SetMargin(o.Margin[0], o.Margin[1], o.Margin[2], o.Margin[3]); err != nil {
365+ return err
366+ }
367+ return ls.SetKeyboardInteractivity(o.Keyboard)
368+}
369+
370+// outputRemoved closes the window when its selected output disappears.
371+type outputRemoved struct{ w *Window }
372+
373+func (h outputRemoved) HandleRegistryGlobalRemove(ev wlturbo.RegistryGlobalRemoveEvent) {
374+ if h.w.layerOutputSelected && ev.Name == h.w.layerOutputGlobal {
375+ h.w.postClosed()
376+ }
377+}
378+
379+func (w *Window) postClosed() {
380+ if w.readerStarted.Load() {
381+ w.post(Event{Kind: CloseEvent})
382+ return
383+ }
384+ w.Closed = true
385+}
386+
387+// closeLayer destroys the layer role and its factory; the wl_surface goes last.
388+func (w *Window) closeLayer() {
389+ if w.LayerSurface != nil {
390+ _ = w.LayerSurface.Destroy()
391+ w.LayerSurface = nil
392+ }
393+ if w.LayerShell != nil {
394+ // zwlr_layer_shell_v1.destroy needs version 3; otherwise forget it locally.
395+ if w.LayerShell.Destroy() != nil {
396+ if c := w.LayerShell.Context(); c != nil {
397+ c.Unregister(w.LayerShell)
398+ }
399+ }
400+ w.LayerShell = nil
401+ }
402+}
A internal/platform/wayland/layer_test.go +526 -0
@@ -0,0 +1,526 @@
1+//go:build linux
2+
3+package wayland
4+
5+import (
6+ "context"
7+ "encoding/binary"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/bnema/nefergui/internal/harness"
19+ "github.com/bnema/wlturbo"
20+ "github.com/bnema/wlturbo/protocol/core"
21+ "github.com/bnema/wlturbo/wl"
22+)
23+
24+func TestLayerConstantsMatchProtocol(t *testing.T) {
25+ if LayerBackground != 0 || LayerBottom != 1 || LayerTop != 2 || LayerOverlay != 3 {
26+ t.Fatal("layer values")
27+ }
28+ if AnchorTop != 1 || AnchorBottom != 2 || AnchorLeft != 4 || AnchorRight != 8 {
29+ t.Fatal("anchor bits")
30+ }
31+ if KeyboardNone != 0 || KeyboardExclusive != 1 || KeyboardOnDemand != 2 {
32+ t.Fatal("keyboard modes")
33+ }
34+}
35+
36+func TestSurfaceOptionsValidate(t *testing.T) {
37+ ok := LayerOptions{Layer: LayerOverlay, Anchor: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom, Keyboard: KeyboardOnDemand}
38+ for name, tc := range map[string]struct {
39+ opts SurfaceOptions
40+ bad bool
41+ }{
42+ "zero": {},
43+ "valid layer": {opts: SurfaceOptions{Layer: &ok}},
44+ "empty passthru": {opts: SurfaceOptions{InputRects: []Rect{}}},
45+ "layer 4": {opts: SurfaceOptions{Layer: &LayerOptions{Layer: 4}}, bad: true},
46+ "anchor 16": {opts: SurfaceOptions{Layer: &LayerOptions{Anchor: 16}}, bad: true},
47+ "zone -2": {opts: SurfaceOptions{Layer: &LayerOptions{ExclusiveZone: -2}}, bad: true},
48+ "zone -1": {opts: SurfaceOptions{Layer: &LayerOptions{ExclusiveZone: -1}}},
49+ "keyboard 3": {opts: SurfaceOptions{Layer: &LayerOptions{Keyboard: 3}}, bad: true},
50+ "namespace NUL": {opts: SurfaceOptions{Layer: &LayerOptions{Namespace: "a\x00b"}}, bad: true},
51+ "zero-size rect": {opts: SurfaceOptions{InputRects: []Rect{{0, 0, 0, 5}}}, bad: true},
52+ "overflow rect": {opts: SurfaceOptions{InputRects: []Rect{{1<<31 - 2, 0, 5, 5}}}, bad: true},
53+ "negative origin": {opts: SurfaceOptions{InputRects: []Rect{{-5, -5, 10, 10}}}},
54+ } {
55+ if err := tc.opts.Validate(); (err != nil) != tc.bad {
56+ t.Errorf("%s: err=%v", name, err)
57+ }
58+ }
59+ if _, err := ConnectWithOptions(context.Background(), "", 10, 10, false, SurfaceOptions{Layer: &LayerOptions{Layer: 9}}); err == nil || !strings.Contains(err.Error(), "invalid layer") {
60+ t.Fatalf("connect must validate before dialing: %v", err)
61+ }
62+}
63+
64+func TestLayerConfigureSizeBounds(t *testing.T) {
65+ if w, h, err := layerConfigureSize(0, 16384); err != nil || w != 0 || h != 16384 {
66+ t.Fatal(w, h, err)
67+ }
68+ if _, _, err := layerConfigureSize(16385, 1); err == nil {
69+ t.Fatal("oversize accepted")
70+ }
71+ w := &Window{Width: 100, Height: 50}
72+ w.applyLayerSize(0, 40)
73+ if w.Width != 100 || w.Height != 40 || !w.Dirty {
74+ t.Fatalf("%+v", w)
75+ }
76+}
77+
78+// Harness acceptance: a real NeferWL compositor and the real client code.
79+func runLayerHarness(t *testing.T, child string) (string, string) {
80+ t.Helper()
81+ if os.Getenv("NEFERGUI_HARNESS") != "1" {
82+ t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the layer-shell harness")
83+ }
84+ root := filepath.Join(t.TempDir(), "artifacts")
85+ result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Timeout: 30 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^" + child + "$", "-test.v"}})
86+ out, _ := os.ReadFile(result.Artifacts["client_stdout"])
87+ errOut, _ := os.ReadFile(result.Artifacts["client_stderr"])
88+ if code != harness.Pass {
89+ t.Fatalf("code=%d status=%s error=%s\nstdout:\n%s\nstderr:\n%s", code, result.Status, result.Error, out, errOut)
90+ }
91+ return string(out), string(errOut)
92+}
93+
94+func TestHarnessLayerSurface(t *testing.T) {
95+ out, _ := runLayerHarness(t, "TestHarnessLayerSurfaceClient")
96+ for _, want := range []string{"LAYER configured", "LAYER height=40", "LAYER input-passthrough", "LAYER input-default", "LAYER closed"} {
97+ if !strings.Contains(out, want) {
98+ t.Fatalf("missing %q in:\n%s", want, out)
99+ }
100+ }
101+}
102+
103+func TestHarnessLayerOutputSelection(t *testing.T) {
104+ out, _ := runLayerHarness(t, "TestHarnessLayerOutputClient")
105+ for _, want := range []string{"OUTPUT exact ok", "OUTPUT missing rejected"} {
106+ if !strings.Contains(out, want) {
107+ t.Fatalf("missing %q in:\n%s", want, out)
108+ }
109+ }
110+}
111+
112+func TestHarnessXDGDefaultUnchanged(t *testing.T) {
113+ out, _ := runLayerHarness(t, "TestHarnessXDGClient")
114+ if !strings.Contains(out, "XDG configured toplevel") {
115+ t.Fatal(out)
116+ }
117+}
118+
119+func harnessChild(t *testing.T) {
120+ t.Helper()
121+ if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
122+ t.Skip("harness child only")
123+ }
124+}
125+
126+func TestHarnessLayerSurfaceClient(t *testing.T) {
127+ harnessChild(t)
128+ w, err := ConnectWithOptions(context.Background(), "", 100, 40, true, SurfaceOptions{
129+ InputRects: []Rect{{0, 0, 10, 10}},
130+ Layer: &LayerOptions{
131+ Layer: LayerTop, Anchor: AnchorTop | AnchorLeft | AnchorRight, Keyboard: KeyboardOnDemand,
132+ ExclusiveZone: 40, Margin: [4]int32{2, 3, 4, 5}, Namespace: "nefergui-test",
133+ },
134+ })
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if !w.IsLayer() || w.Toplevel != nil || w.XdgSurface != nil || w.Shell != nil {
139+ t.Fatal("layer window must not create xdg objects")
140+ }
141+ if !w.Configured || w.Width < 100 {
142+ t.Fatalf("not configured: %+v", w)
143+ }
144+ fmt.Println("LAYER configured", w.Width, w.Height)
145+ if w.Height == 40 {
146+ fmt.Println("LAYER height=40")
147+ }
148+ if err = w.SetTitle("ignored"); err != nil {
149+ t.Fatal(err)
150+ }
151+ // Empty non-nil region: pass-through; nil: default full surface.
152+ if err = w.SetInputRects([]Rect{}); err != nil {
153+ t.Fatal(err)
154+ }
155+ if err = w.SetInputRects([]Rect{}); err != nil {
156+ t.Fatal(err)
157+ }
158+ fmt.Println("LAYER input-passthrough")
159+ if err = w.SetInputRects(nil); err != nil {
160+ t.Fatal(err)
161+ }
162+ fmt.Println("LAYER input-default")
163+ // A roundtrip proves the compositor processed and accepted every request
164+ // (a protocol error would fail it); only then does the reader take over.
165+ if err = w.Display.Roundtrip(); err != nil {
166+ t.Fatal(err)
167+ }
168+ // Hand dispatch to the reader and prove it works: a close from the
169+ // owner side must still tear down cleanly.
170+ events := w.StartReader()
171+ for drained := false; !drained; {
172+ select {
173+ case ev := <-events:
174+ if ev.Kind == TransportError || ev.Kind == CloseEvent {
175+ t.Fatalf("unexpected %+v", ev)
176+ }
177+ if err = w.Apply(ev); err != nil {
178+ t.Fatal(err)
179+ }
180+ default:
181+ drained = true
182+ }
183+ }
184+ if err = w.Close(); err != nil {
185+ t.Fatal(err)
186+ }
187+ fmt.Println("LAYER closed")
188+}
189+
190+func TestHarnessLayerOutputClient(t *testing.T) {
191+ harnessChild(t)
192+ d, err := wl.Connect("")
193+ if err != nil {
194+ t.Fatal(err)
195+ }
196+ if err = d.Roundtrip(); err != nil {
197+ t.Fatal(err)
198+ }
199+ var name string
200+ for _, g := range d.Registry().GetGlobals() {
201+ if g.Interface != core.OutputInterface {
202+ continue
203+ }
204+ if g.Version < outputNameVersion {
205+ t.Fatalf("wl_output v%d lacks names", g.Version)
206+ }
207+ o := core.NewOutput(d.Context())
208+ o.OnName(func(n string) { name = n })
209+ if err = d.Registry().Bind(g.Name, core.OutputInterface, outputNameVersion, o); err != nil {
210+ t.Fatal(err)
211+ }
212+ break
213+ }
214+ if err = d.Roundtrip(); err != nil {
215+ t.Fatal(err)
216+ }
217+ _ = d.Close()
218+ if name == "" {
219+ t.Fatal("no output name")
220+ }
221+ w, err := ConnectWithOptions(context.Background(), "", 100, 40, false, SurfaceOptions{Layer: &LayerOptions{Output: name, Layer: LayerBottom, Anchor: AnchorBottom | AnchorLeft | AnchorRight}})
222+ if err != nil {
223+ t.Fatalf("output %q: %v", name, err)
224+ }
225+ if err = w.Close(); err != nil {
226+ t.Fatal(err)
227+ }
228+ fmt.Println("OUTPUT exact ok", name)
229+ _, err = ConnectWithOptions(context.Background(), "", 100, 40, false, SurfaceOptions{Layer: &LayerOptions{Output: name + "-missing"}})
230+ var ce *CapabilityError
231+ if !errors.As(err, &ce) {
232+ t.Fatalf("missing output error: %v", err)
233+ }
234+ fmt.Println("OUTPUT missing rejected")
235+}
236+
237+func TestHarnessXDGClient(t *testing.T) {
238+ harnessChild(t)
239+ w, err := Connect("", 100, 50, false)
240+ if err != nil {
241+ t.Fatal(err)
242+ }
243+ if w.IsLayer() || w.Toplevel == nil || w.XdgSurface == nil || !w.Configured {
244+ t.Fatal("default must be xdg toplevel")
245+ }
246+ if err = w.SetTitle("x"); err != nil {
247+ t.Fatal(err)
248+ }
249+ fmt.Println("XDG configured toplevel")
250+ if err = w.Close(); err != nil {
251+ t.Fatal(err)
252+ }
253+}
254+
255+// wireServer is a real unix-socket Wayland peer; tests speak raw frames.
256+type wireServer struct {
257+ conn net.Conn
258+ path string
259+}
260+
261+func newWireServer(t *testing.T) (*wireServer, func() net.Conn) {
262+ t.Helper()
263+ dir := t.TempDir()
264+ path := filepath.Join(dir, "wl-test")
265+ ln, err := net.Listen("unix", path)
266+ if err != nil {
267+ t.Fatal(err)
268+ }
269+ t.Cleanup(func() { _ = ln.Close() })
270+ srv := &wireServer{path: path}
271+ accepted := make(chan net.Conn, 1)
272+ go func() {
273+ c, e := ln.Accept()
274+ if e == nil {
275+ accepted <- c
276+ }
277+ }()
278+ return srv, func() net.Conn {
279+ select {
280+ case c := <-accepted:
281+ srv.conn = c
282+ t.Cleanup(func() { _ = c.Close() })
283+ return c
284+ case <-time.After(5 * time.Second):
285+ t.Fatal("client never connected")
286+ return nil
287+ }
288+ }
289+}
290+
291+func frame(object uint32, opcode uint16, args ...uint32) []byte {
292+ b := make([]byte, 8+4*len(args))
293+ binary.NativeEndian.PutUint32(b, object)
294+ binary.NativeEndian.PutUint32(b[4:], uint32(len(b))<<16|uint32(opcode))
295+ for i, a := range args {
296+ binary.NativeEndian.PutUint32(b[8+4*i:], a)
297+ }
298+ return b
299+}
300+
301+// A compositor that never answers must not block startup past ctx; the
302+// connection is closed so the server sees EOF.
303+func TestConnectWithOptionsCancelsHangingServer(t *testing.T) {
304+ srv, accept := newWireServer(t)
305+ ctx, cancel := context.WithCancel(context.Background())
306+ type result struct {
307+ w *Window
308+ err error
309+ }
310+ done := make(chan result, 1)
311+ go func() {
312+ w, err := ConnectWithOptions(ctx, srv.path, 100, 40, false, SurfaceOptions{})
313+ done <- result{w, err}
314+ }()
315+ conn := accept()
316+ buf := make([]byte, 64)
317+ if _, err := conn.Read(buf); err != nil { // get_registry / sync requests: client is now blocked
318+ t.Fatal(err)
319+ }
320+ select {
321+ case <-done:
322+ t.Fatal("returned before cancel")
323+ case <-time.After(100 * time.Millisecond):
324+ }
325+ cancel()
326+ select {
327+ case r := <-done:
328+ if r.w != nil || !errors.Is(r.err, context.Canceled) {
329+ t.Fatalf("window=%v err=%v", r.w, r.err)
330+ }
331+ case <-time.After(5 * time.Second):
332+ t.Fatal("ConnectWithOptions still blocked after cancel")
333+ }
334+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
335+ for {
336+ if _, err := conn.Read(buf); err != nil {
337+ if !errors.Is(err, io.EOF) {
338+ t.Fatalf("server expected EOF (client closed), got %v", err)
339+ }
340+ break
341+ }
342+ }
343+}
344+
345+func TestConnectWithOptionsPrecancelledAndNil(t *testing.T) {
346+ ctx, cancel := context.WithCancel(context.Background())
347+ cancel()
348+ if _, err := ConnectWithOptions(ctx, "/nonexistent", 1, 1, false, SurfaceOptions{}); !errors.Is(err, context.Canceled) {
349+ t.Fatal(err)
350+ }
351+ //lint:ignore SA1012 nil context is an explicit error case
352+ if _, err := ConnectWithOptions(nil, "/nonexistent", 1, 1, false, SurfaceOptions{}); err == nil {
353+ t.Fatal("nil context accepted")
354+ }
355+}
356+
357+// Removal of the selected output's registry global closes the window, before
358+// and after the reader starts; other globals are ignored.
359+func TestSelectedOutputRemovalClosesWindow(t *testing.T) {
360+ for _, reader := range []bool{false, true} {
361+ t.Run(fmt.Sprintf("reader=%v", reader), func(t *testing.T) {
362+ srv, accept := newWireServer(t)
363+ type dialed struct {
364+ d *wlturbo.Display
365+ err error
366+ }
367+ ch := make(chan dialed, 1)
368+ go func() {
369+ d, err := wlturbo.Connect(srv.path)
370+ ch <- dialed{d, err}
371+ }()
372+ conn := accept()
373+ r := <-ch
374+ if r.err != nil {
375+ t.Fatal(r.err)
376+ }
377+ d := r.d
378+ defer d.Close()
379+ w := &Window{Display: d, events: make(chan Event, 8), readerStop: make(chan struct{}), layerOutputSelected: true, layerOutputGlobal: 7}
380+ d.Registry().AddGlobalRemoveHandler(outputRemoved{w})
381+ if reader {
382+ w.StartReader()
383+ }
384+ regID := d.Registry().ID()
385+ if _, err := conn.Write(frame(regID, 1, 9)); err != nil { // other global
386+ t.Fatal(err)
387+ }
388+ if !reader {
389+ if err := d.Dispatch(); err != nil {
390+ t.Fatal(err)
391+ }
392+ if w.Closed {
393+ t.Fatal("unrelated removal closed the window")
394+ }
395+ } else {
396+ select {
397+ case ev := <-w.Events():
398+ t.Fatalf("unrelated removal posted %+v", ev)
399+ case <-time.After(100 * time.Millisecond):
400+ }
401+ }
402+ if _, err := conn.Write(frame(regID, 1, 7)); err != nil {
403+ t.Fatal(err)
404+ }
405+ if !reader {
406+ if err := d.Dispatch(); err != nil {
407+ t.Fatal(err)
408+ }
409+ if !w.Closed {
410+ t.Fatal("selected output removal did not close")
411+ }
412+ return
413+ }
414+ select {
415+ case ev := <-w.Events():
416+ if ev.Kind != CloseEvent {
417+ t.Fatalf("event %+v", ev)
418+ }
419+ case <-time.After(5 * time.Second):
420+ t.Fatal("no CloseEvent")
421+ }
422+ close(w.readerStop)
423+ _ = d.Close()
424+ <-w.readerDone
425+ })
426+ }
427+}
428+
429+// surfaceRequestCounts reads raw client frames until the sentinel (wl_surface.damage on
430+// surface) and returns "object/opcode" counts for the surface.
431+func surfaceRequestCounts(t *testing.T, conn net.Conn, surface uint32) map[uint16]int {
432+ t.Helper()
433+ counts := map[uint16]int{}
434+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
435+ hdr := make([]byte, 8)
436+ for {
437+ if _, err := io.ReadFull(conn, hdr); err != nil {
438+ t.Fatal(err)
439+ }
440+ obj := binary.NativeEndian.Uint32(hdr)
441+ word := binary.NativeEndian.Uint32(hdr[4:])
442+ size, op := int(word>>16), uint16(word)
443+ if size < 8 {
444+ t.Fatalf("bad frame size %d", size)
445+ }
446+ if _, err := io.CopyN(io.Discard, conn, int64(size-8)); err != nil {
447+ t.Fatal(err)
448+ }
449+ if obj != surface {
450+ continue
451+ }
452+ if op == 2 { // wl_surface.damage sentinel
453+ return counts
454+ }
455+ counts[op]++
456+ }
457+}
458+
459+// StageInputRects never commits; SetInputRects commits only a real change on a
460+// configured window; an unchanged request sends nothing. Counted on the wire.
461+func TestInputRectRequestsOnWire(t *testing.T) {
462+ const opSetInputRegion, opCommit = 5, 6
463+ srv, accept := newWireServer(t)
464+ type dialed struct {
465+ d *wlturbo.Display
466+ err error
467+ }
468+ ch := make(chan dialed, 1)
469+ go func() {
470+ d, err := wlturbo.Connect(srv.path)
471+ ch <- dialed{d, err}
472+ }()
473+ conn := accept()
474+ r := <-ch
475+ if r.err != nil {
476+ t.Fatal(r.err)
477+ }
478+ d := r.d
479+ defer d.Close()
480+ comp := core.NewCompositor(d.Context())
481+ if err := d.Registry().Bind(1, core.CompositorInterface, 6, comp); err != nil {
482+ t.Fatal(err)
483+ }
484+ surface, err := comp.CreateSurface()
485+ if err != nil {
486+ t.Fatal(err)
487+ }
488+ w := &Window{Display: d, Compositor: comp, Surface: surface}
489+ step := func(name string, run func() error, wantSet, wantCommit int) {
490+ t.Helper()
491+ if err := run(); err != nil {
492+ t.Fatal(name, err)
493+ }
494+ if err := surface.Damage(0, 0, 1, 1); err != nil {
495+ t.Fatal(err)
496+ }
497+ got := surfaceRequestCounts(t, conn, surface.ID())
498+ if got[opSetInputRegion] != wantSet || got[opCommit] != wantCommit {
499+ t.Fatalf("%s: set_input_region=%d commit=%d, want %d/%d", name, got[opSetInputRegion], got[opCommit], wantSet, wantCommit)
500+ }
501+ }
502+ rects := []Rect{{0, 0, 10, 10}, {20, 0, 5, 5}}
503+ step("stage", func() error { return w.StageInputRects(rects) }, 1, 0)
504+ step("stage identical", func() error { return w.StageInputRects(append([]Rect(nil), rects...)) }, 0, 0)
505+ step("set identical while configured", func() error { w.Configured = true; return w.SetInputRects(rects) }, 0, 0)
506+ step("set changed", func() error { return w.SetInputRects([]Rect{{1, 1, 2, 2}}) }, 1, 1)
507+ step("set passthrough", func() error { return w.SetInputRects([]Rect{}) }, 1, 1)
508+ step("set passthrough again", func() error { return w.SetInputRects([]Rect{}) }, 0, 0)
509+ step("set default", func() error { return w.SetInputRects(nil) }, 1, 1)
510+ step("set default again", func() error { return w.SetInputRects(nil) }, 0, 0)
511+ w.Configured = false
512+ step("unconfigured change stages only", func() error { return w.SetInputRects(rects) }, 1, 0)
513+ before := len(w.inputRects)
514+ step("invalid stays unsent", func() error {
515+ if err := w.StageInputRects([]Rect{{0, 0, 0, 1}}); err == nil {
516+ t.Fatal("invalid rect accepted")
517+ }
518+ return nil
519+ }, 0, 0)
520+ if len(w.inputRects) != before {
521+ t.Fatal("invalid request changed cached state")
522+ }
523+ if n := testing.AllocsPerRun(100, func() { _ = w.StageInputRects(rects) }); n != 0 {
524+ t.Fatalf("unchanged stage allocates %v", n)
525+ }
526+}
@@ -0,0 +1,526 @@
1+//go:build linux
2+
3+package wayland
4+
5+import (
6+ "context"
7+ "encoding/binary"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/bnema/nefergui/internal/harness"
19+ "github.com/bnema/wlturbo"
20+ "github.com/bnema/wlturbo/protocol/core"
21+ "github.com/bnema/wlturbo/wl"
22+)
23+
24+func TestLayerConstantsMatchProtocol(t *testing.T) {
25+ if LayerBackground != 0 || LayerBottom != 1 || LayerTop != 2 || LayerOverlay != 3 {
26+ t.Fatal("layer values")
27+ }
28+ if AnchorTop != 1 || AnchorBottom != 2 || AnchorLeft != 4 || AnchorRight != 8 {
29+ t.Fatal("anchor bits")
30+ }
31+ if KeyboardNone != 0 || KeyboardExclusive != 1 || KeyboardOnDemand != 2 {
32+ t.Fatal("keyboard modes")
33+ }
34+}
35+
36+func TestSurfaceOptionsValidate(t *testing.T) {
37+ ok := LayerOptions{Layer: LayerOverlay, Anchor: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom, Keyboard: KeyboardOnDemand}
38+ for name, tc := range map[string]struct {
39+ opts SurfaceOptions
40+ bad bool
41+ }{
42+ "zero": {},
43+ "valid layer": {opts: SurfaceOptions{Layer: &ok}},
44+ "empty passthru": {opts: SurfaceOptions{InputRects: []Rect{}}},
45+ "layer 4": {opts: SurfaceOptions{Layer: &LayerOptions{Layer: 4}}, bad: true},
46+ "anchor 16": {opts: SurfaceOptions{Layer: &LayerOptions{Anchor: 16}}, bad: true},
47+ "zone -2": {opts: SurfaceOptions{Layer: &LayerOptions{ExclusiveZone: -2}}, bad: true},
48+ "zone -1": {opts: SurfaceOptions{Layer: &LayerOptions{ExclusiveZone: -1}}},
49+ "keyboard 3": {opts: SurfaceOptions{Layer: &LayerOptions{Keyboard: 3}}, bad: true},
50+ "namespace NUL": {opts: SurfaceOptions{Layer: &LayerOptions{Namespace: "a\x00b"}}, bad: true},
51+ "zero-size rect": {opts: SurfaceOptions{InputRects: []Rect{{0, 0, 0, 5}}}, bad: true},
52+ "overflow rect": {opts: SurfaceOptions{InputRects: []Rect{{1<<31 - 2, 0, 5, 5}}}, bad: true},
53+ "negative origin": {opts: SurfaceOptions{InputRects: []Rect{{-5, -5, 10, 10}}}},
54+ } {
55+ if err := tc.opts.Validate(); (err != nil) != tc.bad {
56+ t.Errorf("%s: err=%v", name, err)
57+ }
58+ }
59+ if _, err := ConnectWithOptions(context.Background(), "", 10, 10, false, SurfaceOptions{Layer: &LayerOptions{Layer: 9}}); err == nil || !strings.Contains(err.Error(), "invalid layer") {
60+ t.Fatalf("connect must validate before dialing: %v", err)
61+ }
62+}
63+
64+func TestLayerConfigureSizeBounds(t *testing.T) {
65+ if w, h, err := layerConfigureSize(0, 16384); err != nil || w != 0 || h != 16384 {
66+ t.Fatal(w, h, err)
67+ }
68+ if _, _, err := layerConfigureSize(16385, 1); err == nil {
69+ t.Fatal("oversize accepted")
70+ }
71+ w := &Window{Width: 100, Height: 50}
72+ w.applyLayerSize(0, 40)
73+ if w.Width != 100 || w.Height != 40 || !w.Dirty {
74+ t.Fatalf("%+v", w)
75+ }
76+}
77+
78+// Harness acceptance: a real NeferWL compositor and the real client code.
79+func runLayerHarness(t *testing.T, child string) (string, string) {
80+ t.Helper()
81+ if os.Getenv("NEFERGUI_HARNESS") != "1" {
82+ t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the layer-shell harness")
83+ }
84+ root := filepath.Join(t.TempDir(), "artifacts")
85+ result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Timeout: 30 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^" + child + "$", "-test.v"}})
86+ out, _ := os.ReadFile(result.Artifacts["client_stdout"])
87+ errOut, _ := os.ReadFile(result.Artifacts["client_stderr"])
88+ if code != harness.Pass {
89+ t.Fatalf("code=%d status=%s error=%s\nstdout:\n%s\nstderr:\n%s", code, result.Status, result.Error, out, errOut)
90+ }
91+ return string(out), string(errOut)
92+}
93+
94+func TestHarnessLayerSurface(t *testing.T) {
95+ out, _ := runLayerHarness(t, "TestHarnessLayerSurfaceClient")
96+ for _, want := range []string{"LAYER configured", "LAYER height=40", "LAYER input-passthrough", "LAYER input-default", "LAYER closed"} {
97+ if !strings.Contains(out, want) {
98+ t.Fatalf("missing %q in:\n%s", want, out)
99+ }
100+ }
101+}
102+
103+func TestHarnessLayerOutputSelection(t *testing.T) {
104+ out, _ := runLayerHarness(t, "TestHarnessLayerOutputClient")
105+ for _, want := range []string{"OUTPUT exact ok", "OUTPUT missing rejected"} {
106+ if !strings.Contains(out, want) {
107+ t.Fatalf("missing %q in:\n%s", want, out)
108+ }
109+ }
110+}
111+
112+func TestHarnessXDGDefaultUnchanged(t *testing.T) {
113+ out, _ := runLayerHarness(t, "TestHarnessXDGClient")
114+ if !strings.Contains(out, "XDG configured toplevel") {
115+ t.Fatal(out)
116+ }
117+}
118+
119+func harnessChild(t *testing.T) {
120+ t.Helper()
121+ if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
122+ t.Skip("harness child only")
123+ }
124+}
125+
126+func TestHarnessLayerSurfaceClient(t *testing.T) {
127+ harnessChild(t)
128+ w, err := ConnectWithOptions(context.Background(), "", 100, 40, true, SurfaceOptions{
129+ InputRects: []Rect{{0, 0, 10, 10}},
130+ Layer: &LayerOptions{
131+ Layer: LayerTop, Anchor: AnchorTop | AnchorLeft | AnchorRight, Keyboard: KeyboardOnDemand,
132+ ExclusiveZone: 40, Margin: [4]int32{2, 3, 4, 5}, Namespace: "nefergui-test",
133+ },
134+ })
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if !w.IsLayer() || w.Toplevel != nil || w.XdgSurface != nil || w.Shell != nil {
139+ t.Fatal("layer window must not create xdg objects")
140+ }
141+ if !w.Configured || w.Width < 100 {
142+ t.Fatalf("not configured: %+v", w)
143+ }
144+ fmt.Println("LAYER configured", w.Width, w.Height)
145+ if w.Height == 40 {
146+ fmt.Println("LAYER height=40")
147+ }
148+ if err = w.SetTitle("ignored"); err != nil {
149+ t.Fatal(err)
150+ }
151+ // Empty non-nil region: pass-through; nil: default full surface.
152+ if err = w.SetInputRects([]Rect{}); err != nil {
153+ t.Fatal(err)
154+ }
155+ if err = w.SetInputRects([]Rect{}); err != nil {
156+ t.Fatal(err)
157+ }
158+ fmt.Println("LAYER input-passthrough")
159+ if err = w.SetInputRects(nil); err != nil {
160+ t.Fatal(err)
161+ }
162+ fmt.Println("LAYER input-default")
163+ // A roundtrip proves the compositor processed and accepted every request
164+ // (a protocol error would fail it); only then does the reader take over.
165+ if err = w.Display.Roundtrip(); err != nil {
166+ t.Fatal(err)
167+ }
168+ // Hand dispatch to the reader and prove it works: a close from the
169+ // owner side must still tear down cleanly.
170+ events := w.StartReader()
171+ for drained := false; !drained; {
172+ select {
173+ case ev := <-events:
174+ if ev.Kind == TransportError || ev.Kind == CloseEvent {
175+ t.Fatalf("unexpected %+v", ev)
176+ }
177+ if err = w.Apply(ev); err != nil {
178+ t.Fatal(err)
179+ }
180+ default:
181+ drained = true
182+ }
183+ }
184+ if err = w.Close(); err != nil {
185+ t.Fatal(err)
186+ }
187+ fmt.Println("LAYER closed")
188+}
189+
190+func TestHarnessLayerOutputClient(t *testing.T) {
191+ harnessChild(t)
192+ d, err := wl.Connect("")
193+ if err != nil {
194+ t.Fatal(err)
195+ }
196+ if err = d.Roundtrip(); err != nil {
197+ t.Fatal(err)
198+ }
199+ var name string
200+ for _, g := range d.Registry().GetGlobals() {
201+ if g.Interface != core.OutputInterface {
202+ continue
203+ }
204+ if g.Version < outputNameVersion {
205+ t.Fatalf("wl_output v%d lacks names", g.Version)
206+ }
207+ o := core.NewOutput(d.Context())
208+ o.OnName(func(n string) { name = n })
209+ if err = d.Registry().Bind(g.Name, core.OutputInterface, outputNameVersion, o); err != nil {
210+ t.Fatal(err)
211+ }
212+ break
213+ }
214+ if err = d.Roundtrip(); err != nil {
215+ t.Fatal(err)
216+ }
217+ _ = d.Close()
218+ if name == "" {
219+ t.Fatal("no output name")
220+ }
221+ w, err := ConnectWithOptions(context.Background(), "", 100, 40, false, SurfaceOptions{Layer: &LayerOptions{Output: name, Layer: LayerBottom, Anchor: AnchorBottom | AnchorLeft | AnchorRight}})
222+ if err != nil {
223+ t.Fatalf("output %q: %v", name, err)
224+ }
225+ if err = w.Close(); err != nil {
226+ t.Fatal(err)
227+ }
228+ fmt.Println("OUTPUT exact ok", name)
229+ _, err = ConnectWithOptions(context.Background(), "", 100, 40, false, SurfaceOptions{Layer: &LayerOptions{Output: name + "-missing"}})
230+ var ce *CapabilityError
231+ if !errors.As(err, &ce) {
232+ t.Fatalf("missing output error: %v", err)
233+ }
234+ fmt.Println("OUTPUT missing rejected")
235+}
236+
237+func TestHarnessXDGClient(t *testing.T) {
238+ harnessChild(t)
239+ w, err := Connect("", 100, 50, false)
240+ if err != nil {
241+ t.Fatal(err)
242+ }
243+ if w.IsLayer() || w.Toplevel == nil || w.XdgSurface == nil || !w.Configured {
244+ t.Fatal("default must be xdg toplevel")
245+ }
246+ if err = w.SetTitle("x"); err != nil {
247+ t.Fatal(err)
248+ }
249+ fmt.Println("XDG configured toplevel")
250+ if err = w.Close(); err != nil {
251+ t.Fatal(err)
252+ }
253+}
254+
255+// wireServer is a real unix-socket Wayland peer; tests speak raw frames.
256+type wireServer struct {
257+ conn net.Conn
258+ path string
259+}
260+
261+func newWireServer(t *testing.T) (*wireServer, func() net.Conn) {
262+ t.Helper()
263+ dir := t.TempDir()
264+ path := filepath.Join(dir, "wl-test")
265+ ln, err := net.Listen("unix", path)
266+ if err != nil {
267+ t.Fatal(err)
268+ }
269+ t.Cleanup(func() { _ = ln.Close() })
270+ srv := &wireServer{path: path}
271+ accepted := make(chan net.Conn, 1)
272+ go func() {
273+ c, e := ln.Accept()
274+ if e == nil {
275+ accepted <- c
276+ }
277+ }()
278+ return srv, func() net.Conn {
279+ select {
280+ case c := <-accepted:
281+ srv.conn = c
282+ t.Cleanup(func() { _ = c.Close() })
283+ return c
284+ case <-time.After(5 * time.Second):
285+ t.Fatal("client never connected")
286+ return nil
287+ }
288+ }
289+}
290+
291+func frame(object uint32, opcode uint16, args ...uint32) []byte {
292+ b := make([]byte, 8+4*len(args))
293+ binary.NativeEndian.PutUint32(b, object)
294+ binary.NativeEndian.PutUint32(b[4:], uint32(len(b))<<16|uint32(opcode))
295+ for i, a := range args {
296+ binary.NativeEndian.PutUint32(b[8+4*i:], a)
297+ }
298+ return b
299+}
300+
301+// A compositor that never answers must not block startup past ctx; the
302+// connection is closed so the server sees EOF.
303+func TestConnectWithOptionsCancelsHangingServer(t *testing.T) {
304+ srv, accept := newWireServer(t)
305+ ctx, cancel := context.WithCancel(context.Background())
306+ type result struct {
307+ w *Window
308+ err error
309+ }
310+ done := make(chan result, 1)
311+ go func() {
312+ w, err := ConnectWithOptions(ctx, srv.path, 100, 40, false, SurfaceOptions{})
313+ done <- result{w, err}
314+ }()
315+ conn := accept()
316+ buf := make([]byte, 64)
317+ if _, err := conn.Read(buf); err != nil { // get_registry / sync requests: client is now blocked
318+ t.Fatal(err)
319+ }
320+ select {
321+ case <-done:
322+ t.Fatal("returned before cancel")
323+ case <-time.After(100 * time.Millisecond):
324+ }
325+ cancel()
326+ select {
327+ case r := <-done:
328+ if r.w != nil || !errors.Is(r.err, context.Canceled) {
329+ t.Fatalf("window=%v err=%v", r.w, r.err)
330+ }
331+ case <-time.After(5 * time.Second):
332+ t.Fatal("ConnectWithOptions still blocked after cancel")
333+ }
334+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
335+ for {
336+ if _, err := conn.Read(buf); err != nil {
337+ if !errors.Is(err, io.EOF) {
338+ t.Fatalf("server expected EOF (client closed), got %v", err)
339+ }
340+ break
341+ }
342+ }
343+}
344+
345+func TestConnectWithOptionsPrecancelledAndNil(t *testing.T) {
346+ ctx, cancel := context.WithCancel(context.Background())
347+ cancel()
348+ if _, err := ConnectWithOptions(ctx, "/nonexistent", 1, 1, false, SurfaceOptions{}); !errors.Is(err, context.Canceled) {
349+ t.Fatal(err)
350+ }
351+ //lint:ignore SA1012 nil context is an explicit error case
352+ if _, err := ConnectWithOptions(nil, "/nonexistent", 1, 1, false, SurfaceOptions{}); err == nil {
353+ t.Fatal("nil context accepted")
354+ }
355+}
356+
357+// Removal of the selected output's registry global closes the window, before
358+// and after the reader starts; other globals are ignored.
359+func TestSelectedOutputRemovalClosesWindow(t *testing.T) {
360+ for _, reader := range []bool{false, true} {
361+ t.Run(fmt.Sprintf("reader=%v", reader), func(t *testing.T) {
362+ srv, accept := newWireServer(t)
363+ type dialed struct {
364+ d *wlturbo.Display
365+ err error
366+ }
367+ ch := make(chan dialed, 1)
368+ go func() {
369+ d, err := wlturbo.Connect(srv.path)
370+ ch <- dialed{d, err}
371+ }()
372+ conn := accept()
373+ r := <-ch
374+ if r.err != nil {
375+ t.Fatal(r.err)
376+ }
377+ d := r.d
378+ defer d.Close()
379+ w := &Window{Display: d, events: make(chan Event, 8), readerStop: make(chan struct{}), layerOutputSelected: true, layerOutputGlobal: 7}
380+ d.Registry().AddGlobalRemoveHandler(outputRemoved{w})
381+ if reader {
382+ w.StartReader()
383+ }
384+ regID := d.Registry().ID()
385+ if _, err := conn.Write(frame(regID, 1, 9)); err != nil { // other global
386+ t.Fatal(err)
387+ }
388+ if !reader {
389+ if err := d.Dispatch(); err != nil {
390+ t.Fatal(err)
391+ }
392+ if w.Closed {
393+ t.Fatal("unrelated removal closed the window")
394+ }
395+ } else {
396+ select {
397+ case ev := <-w.Events():
398+ t.Fatalf("unrelated removal posted %+v", ev)
399+ case <-time.After(100 * time.Millisecond):
400+ }
401+ }
402+ if _, err := conn.Write(frame(regID, 1, 7)); err != nil {
403+ t.Fatal(err)
404+ }
405+ if !reader {
406+ if err := d.Dispatch(); err != nil {
407+ t.Fatal(err)
408+ }
409+ if !w.Closed {
410+ t.Fatal("selected output removal did not close")
411+ }
412+ return
413+ }
414+ select {
415+ case ev := <-w.Events():
416+ if ev.Kind != CloseEvent {
417+ t.Fatalf("event %+v", ev)
418+ }
419+ case <-time.After(5 * time.Second):
420+ t.Fatal("no CloseEvent")
421+ }
422+ close(w.readerStop)
423+ _ = d.Close()
424+ <-w.readerDone
425+ })
426+ }
427+}
428+
429+// surfaceRequestCounts reads raw client frames until the sentinel (wl_surface.damage on
430+// surface) and returns "object/opcode" counts for the surface.
431+func surfaceRequestCounts(t *testing.T, conn net.Conn, surface uint32) map[uint16]int {
432+ t.Helper()
433+ counts := map[uint16]int{}
434+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
435+ hdr := make([]byte, 8)
436+ for {
437+ if _, err := io.ReadFull(conn, hdr); err != nil {
438+ t.Fatal(err)
439+ }
440+ obj := binary.NativeEndian.Uint32(hdr)
441+ word := binary.NativeEndian.Uint32(hdr[4:])
442+ size, op := int(word>>16), uint16(word)
443+ if size < 8 {
444+ t.Fatalf("bad frame size %d", size)
445+ }
446+ if _, err := io.CopyN(io.Discard, conn, int64(size-8)); err != nil {
447+ t.Fatal(err)
448+ }
449+ if obj != surface {
450+ continue
451+ }
452+ if op == 2 { // wl_surface.damage sentinel
453+ return counts
454+ }
455+ counts[op]++
456+ }
457+}
458+
459+// StageInputRects never commits; SetInputRects commits only a real change on a
460+// configured window; an unchanged request sends nothing. Counted on the wire.
461+func TestInputRectRequestsOnWire(t *testing.T) {
462+ const opSetInputRegion, opCommit = 5, 6
463+ srv, accept := newWireServer(t)
464+ type dialed struct {
465+ d *wlturbo.Display
466+ err error
467+ }
468+ ch := make(chan dialed, 1)
469+ go func() {
470+ d, err := wlturbo.Connect(srv.path)
471+ ch <- dialed{d, err}
472+ }()
473+ conn := accept()
474+ r := <-ch
475+ if r.err != nil {
476+ t.Fatal(r.err)
477+ }
478+ d := r.d
479+ defer d.Close()
480+ comp := core.NewCompositor(d.Context())
481+ if err := d.Registry().Bind(1, core.CompositorInterface, 6, comp); err != nil {
482+ t.Fatal(err)
483+ }
484+ surface, err := comp.CreateSurface()
485+ if err != nil {
486+ t.Fatal(err)
487+ }
488+ w := &Window{Display: d, Compositor: comp, Surface: surface}
489+ step := func(name string, run func() error, wantSet, wantCommit int) {
490+ t.Helper()
491+ if err := run(); err != nil {
492+ t.Fatal(name, err)
493+ }
494+ if err := surface.Damage(0, 0, 1, 1); err != nil {
495+ t.Fatal(err)
496+ }
497+ got := surfaceRequestCounts(t, conn, surface.ID())
498+ if got[opSetInputRegion] != wantSet || got[opCommit] != wantCommit {
499+ t.Fatalf("%s: set_input_region=%d commit=%d, want %d/%d", name, got[opSetInputRegion], got[opCommit], wantSet, wantCommit)
500+ }
501+ }
502+ rects := []Rect{{0, 0, 10, 10}, {20, 0, 5, 5}}
503+ step("stage", func() error { return w.StageInputRects(rects) }, 1, 0)
504+ step("stage identical", func() error { return w.StageInputRects(append([]Rect(nil), rects...)) }, 0, 0)
505+ step("set identical while configured", func() error { w.Configured = true; return w.SetInputRects(rects) }, 0, 0)
506+ step("set changed", func() error { return w.SetInputRects([]Rect{{1, 1, 2, 2}}) }, 1, 1)
507+ step("set passthrough", func() error { return w.SetInputRects([]Rect{}) }, 1, 1)
508+ step("set passthrough again", func() error { return w.SetInputRects([]Rect{}) }, 0, 0)
509+ step("set default", func() error { return w.SetInputRects(nil) }, 1, 1)
510+ step("set default again", func() error { return w.SetInputRects(nil) }, 0, 0)
511+ w.Configured = false
512+ step("unconfigured change stages only", func() error { return w.SetInputRects(rects) }, 1, 0)
513+ before := len(w.inputRects)
514+ step("invalid stays unsent", func() error {
515+ if err := w.StageInputRects([]Rect{{0, 0, 0, 1}}); err == nil {
516+ t.Fatal("invalid rect accepted")
517+ }
518+ return nil
519+ }, 0, 0)
520+ if len(w.inputRects) != before {
521+ t.Fatal("invalid request changed cached state")
522+ }
523+ if n := testing.AllocsPerRun(100, func() { _ = w.StageInputRects(rects) }); n != 0 {
524+ t.Fatalf("unchanged stage allocates %v", n)
525+ }
526+}
A internal/platform/wayland/layershell/generate.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build linux
2+
3+// Package layershell contains local typed bindings for wlr-layer-shell v1.
4+// wlturbo v0.3.0 has no generated layer-shell package, so the bindings are
5+// generated here by its scanner from the vendored protocol XML.
6+package layershell
7+
8+//go:generate go run github.com/bnema/wlturbo/cmd/wlturbo-scanner -p layershell -o wlr-layer-shell-unstable-v1_generated.go -import wl_surface=github.com/bnema/wlturbo/protocol/core -import wl_output=github.com/bnema/wlturbo/protocol/core -import xdg_popup=github.com/bnema/wlturbo/protocol/xdgshell wlr-layer-shell-unstable-v1.xml
@@ -0,0 +1,8 @@
1+//go:build linux
2+
3+// Package layershell contains local typed bindings for wlr-layer-shell v1.
4+// wlturbo v0.3.0 has no generated layer-shell package, so the bindings are
5+// generated here by its scanner from the vendored protocol XML.
6+package layershell
7+
8+//go:generate go run github.com/bnema/wlturbo/cmd/wlturbo-scanner -p layershell -o wlr-layer-shell-unstable-v1_generated.go -import wl_surface=github.com/bnema/wlturbo/protocol/core -import wl_output=github.com/bnema/wlturbo/protocol/core -import xdg_popup=github.com/bnema/wlturbo/protocol/xdgshell wlr-layer-shell-unstable-v1.xml
A internal/platform/wayland/layershell/wlr-layer-shell-unstable-v1.xml +407 -0
@@ -0,0 +1,407 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<protocol name="wlr_layer_shell_unstable_v1">
3+ <copyright>
4+ Copyright © 2017 Drew DeVault
5+
6+ Permission to use, copy, modify, distribute, and sell this
7+ software and its documentation for any purpose is hereby granted
8+ without fee, provided that the above copyright notice appear in
9+ all copies and that both that copyright notice and this permission
10+ notice appear in supporting documentation, and that the name of
11+ the copyright holders not be used in advertising or publicity
12+ pertaining to distribution of the software without specific,
13+ written prior permission. The copyright holders make no
14+ representations about the suitability of this software for any
15+ purpose. It is provided "as is" without express or implied
16+ warranty.
17+
18+ THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
19+ SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
20+ FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
21+ SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
22+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
23+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
24+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
25+ THIS SOFTWARE.
26+ </copyright>
27+
28+ <interface name="zwlr_layer_shell_v1" version="5">
29+ <description summary="create surfaces that are layers of the desktop">
30+ Clients can use this interface to assign the surface_layer role to
31+ wl_surfaces. Such surfaces are assigned to a "layer" of the output and
32+ rendered with a defined z-depth respective to each other. They may also be
33+ anchored to the edges and corners of a screen and specify input handling
34+ semantics. This interface should be suitable for the implementation of
35+ many desktop shell components, and a broad number of other applications
36+ that interact with the desktop.
37+ </description>
38+
39+ <request name="get_layer_surface">
40+ <description summary="create a layer_surface from a surface">
41+ Create a layer surface for an existing surface. This assigns the role of
42+ layer_surface, or raises a protocol error if another role is already
43+ assigned.
44+
45+ Creating a layer surface from a wl_surface which has a buffer attached
46+ or committed is a client error, and any attempts by a client to attach
47+ or manipulate a buffer prior to the first layer_surface.configure call
48+ must also be treated as errors.
49+
50+ After creating a layer_surface object and setting it up, the client
51+ must perform an initial commit without any buffer attached.
52+ The compositor will reply with a layer_surface.configure event.
53+ The client must acknowledge it and is then allowed to attach a buffer
54+ to map the surface.
55+
56+ You may pass NULL for output to allow the compositor to decide which
57+ output to use. Generally this will be the one that the user most
58+ recently interacted with.
59+
60+ Clients can specify a namespace that defines the purpose of the layer
61+ surface.
62+ </description>
63+ <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
64+ <arg name="surface" type="object" interface="wl_surface"/>
65+ <arg name="output" type="object" interface="wl_output" allow-null="true"/>
66+ <arg name="layer" type="uint" enum="layer" summary="layer to add this surface to"/>
67+ <arg name="namespace" type="string" summary="namespace for the layer surface"/>
68+ </request>
69+
70+ <enum name="error">
71+ <entry name="role" value="0" summary="wl_surface has another role"/>
72+ <entry name="invalid_layer" value="1" summary="layer value is invalid"/>
73+ <entry name="already_constructed" value="2" summary="wl_surface has a buffer attached or committed"/>
74+ </enum>
75+
76+ <enum name="layer">
77+ <description summary="available layers for surfaces">
78+ These values indicate which layers a surface can be rendered in. They
79+ are ordered by z depth, bottom-most first. Traditional shell surfaces
80+ will typically be rendered between the bottom and top layers.
81+ Fullscreen shell surfaces are typically rendered at the top layer.
82+ Multiple surfaces can share a single layer, and ordering within a
83+ single layer is undefined.
84+ </description>
85+
86+ <entry name="background" value="0"/>
87+ <entry name="bottom" value="1"/>
88+ <entry name="top" value="2"/>
89+ <entry name="overlay" value="3"/>
90+ </enum>
91+
92+ <!-- Version 3 additions -->
93+
94+ <request name="destroy" type="destructor" since="3">
95+ <description summary="destroy the layer_shell object">
96+ This request indicates that the client will not use the layer_shell
97+ object any more. Objects that have been created through this instance
98+ are not affected.
99+ </description>
100+ </request>
101+ </interface>
102+
103+ <interface name="zwlr_layer_surface_v1" version="5">
104+ <description summary="layer metadata interface">
105+ An interface that may be implemented by a wl_surface, for surfaces that
106+ are designed to be rendered as a layer of a stacked desktop-like
107+ environment.
108+
109+ Layer surface state (layer, size, anchor, exclusive zone,
110+ margin, interactivity) is double-buffered, and will be applied at the
111+ time wl_surface.commit of the corresponding wl_surface is called.
112+
113+ Attaching a null buffer to a layer surface unmaps it.
114+
115+ Unmapping a layer_surface means that the surface cannot be shown by the
116+ compositor until it is explicitly mapped again. The layer_surface
117+ returns to the state it had right after layer_shell.get_layer_surface.
118+ The client can re-map the surface by performing a commit without any
119+ buffer attached, waiting for a configure event and handling it as usual.
120+ </description>
121+
122+ <request name="set_size">
123+ <description summary="sets the size of the surface">
124+ Sets the size of the surface in surface-local coordinates. The
125+ compositor will display the surface centered with respect to its
126+ anchors.
127+
128+ If you pass 0 for either value, the compositor will assign it and
129+ inform you of the assignment in the configure event. You must set your
130+ anchor to opposite edges in the dimensions you omit; not doing so is a
131+ protocol error. Both values are 0 by default.
132+
133+ Size is double-buffered, see wl_surface.commit.
134+ </description>
135+ <arg name="width" type="uint"/>
136+ <arg name="height" type="uint"/>
137+ </request>
138+
139+ <request name="set_anchor">
140+ <description summary="configures the anchor point of the surface">
141+ Requests that the compositor anchor the surface to the specified edges
142+ and corners. If two orthogonal edges are specified (e.g. 'top' and
143+ 'left'), then the anchor point will be the intersection of the edges
144+ (e.g. the top left corner of the output); otherwise the anchor point
145+ will be centered on that edge, or in the center if none is specified.
146+
147+ Anchor is double-buffered, see wl_surface.commit.
148+ </description>
149+ <arg name="anchor" type="uint" enum="anchor"/>
150+ </request>
151+
152+ <request name="set_exclusive_zone">
153+ <description summary="configures the exclusive geometry of this surface">
154+ Requests that the compositor avoids occluding an area with other
155+ surfaces. The compositor's use of this information is
156+ implementation-dependent - do not assume that this region will not
157+ actually be occluded.
158+
159+ A positive value is only meaningful if the surface is anchored to one
160+ edge or an edge and both perpendicular edges. If the surface is not
161+ anchored, anchored to only two perpendicular edges (a corner), anchored
162+ to only two parallel edges or anchored to all edges, a positive value
163+ will be treated the same as zero.
164+
165+ A positive zone is the distance from the edge in surface-local
166+ coordinates to consider exclusive.
167+
168+ Surfaces that do not wish to have an exclusive zone may instead specify
169+ how they should interact with surfaces that do. If set to zero, the
170+ surface indicates that it would like to be moved to avoid occluding
171+ surfaces with a positive exclusive zone. If set to -1, the surface
172+ indicates that it would not like to be moved to accommodate for other
173+ surfaces, and the compositor should extend it all the way to the edges
174+ it is anchored to.
175+
176+ For example, a panel might set its exclusive zone to 10, so that
177+ maximized shell surfaces are not shown on top of it. A notification
178+ might set its exclusive zone to 0, so that it is moved to avoid
179+ occluding the panel, but shell surfaces are shown underneath it. A
180+ wallpaper or lock screen might set their exclusive zone to -1, so that
181+ they stretch below or over the panel.
182+
183+ The default value is 0.
184+
185+ Exclusive zone is double-buffered, see wl_surface.commit.
186+ </description>
187+ <arg name="zone" type="int"/>
188+ </request>
189+
190+ <request name="set_margin">
191+ <description summary="sets a margin from the anchor point">
192+ Requests that the surface be placed some distance away from the anchor
193+ point on the output, in surface-local coordinates. Setting this value
194+ for edges you are not anchored to has no effect.
195+
196+ The exclusive zone includes the margin.
197+
198+ Margin is double-buffered, see wl_surface.commit.
199+ </description>
200+ <arg name="top" type="int"/>
201+ <arg name="right" type="int"/>
202+ <arg name="bottom" type="int"/>
203+ <arg name="left" type="int"/>
204+ </request>
205+
206+ <enum name="keyboard_interactivity">
207+ <description summary="types of keyboard interaction possible for a layer shell surface">
208+ Types of keyboard interaction possible for layer shell surfaces. The
209+ rationale for this is twofold: (1) some applications are not interested
210+ in keyboard events and not allowing them to be focused can improve the
211+ desktop experience; (2) some applications will want to take exclusive
212+ keyboard focus.
213+ </description>
214+
215+ <entry name="none" value="0">
216+ <description summary="no keyboard focus is possible">
217+ This value indicates that this surface is not interested in keyboard
218+ events and the compositor should never assign it the keyboard focus.
219+
220+ This is the default value, set for newly created layer shell surfaces.
221+
222+ This is useful for e.g. desktop widgets that display information or
223+ only have interaction with non-keyboard input devices.
224+ </description>
225+ </entry>
226+ <entry name="exclusive" value="1">
227+ <description summary="request exclusive keyboard focus">
228+ Request exclusive keyboard focus if this surface is above the shell surface layer.
229+
230+ For the top and overlay layers, the seat will always give
231+ exclusive keyboard focus to the top-most layer which has keyboard
232+ interactivity set to exclusive. If this layer contains multiple
233+ surfaces with keyboard interactivity set to exclusive, the compositor
234+ determines the one receiving keyboard events in an implementation-
235+ defined manner. In this case, no guarantee is made when this surface
236+ will receive keyboard focus (if ever).
237+
238+ For the bottom and background layers, the compositor is allowed to use
239+ normal focus semantics.
240+
241+ This setting is mainly intended for applications that need to ensure
242+ they receive all keyboard events, such as a lock screen or a password
243+ prompt.
244+ </description>
245+ </entry>
246+ <entry name="on_demand" value="2" since="4">
247+ <description summary="request regular keyboard focus semantics">
248+ This requests the compositor to allow this surface to be focused and
249+ unfocused by the user in an implementation-defined manner. The user
250+ should be able to unfocus this surface even regardless of the layer
251+ it is on.
252+
253+ Typically, the compositor will want to use its normal mechanism to
254+ manage keyboard focus between layer shell surfaces with this setting
255+ and regular toplevels on the desktop layer (e.g. click to focus).
256+ Nevertheless, it is possible for a compositor to require a special
257+ interaction to focus or unfocus layer shell surfaces (e.g. requiring
258+ a click even if focus follows the mouse normally, or providing a
259+ keybinding to switch focus between layers).
260+
261+ This setting is mainly intended for desktop shell components (e.g.
262+ panels) that allow keyboard interaction. Using this option can allow
263+ implementing a desktop shell that can be fully usable without the
264+ mouse.
265+ </description>
266+ </entry>
267+ </enum>
268+
269+ <request name="set_keyboard_interactivity">
270+ <description summary="requests keyboard events">
271+ Set how keyboard events are delivered to this surface. By default,
272+ layer shell surfaces do not receive keyboard events; this request can
273+ be used to change this.
274+
275+ This setting is inherited by child surfaces set by the get_popup
276+ request.
277+
278+ Layer surfaces receive pointer, touch, and tablet events normally. If
279+ you do not want to receive them, set the input region on your surface
280+ to an empty region.
281+
282+ Keyboard interactivity is double-buffered, see wl_surface.commit.
283+ </description>
284+ <arg name="keyboard_interactivity" type="uint" enum="keyboard_interactivity"/>
285+ </request>
286+
287+ <request name="get_popup">
288+ <description summary="assign this layer_surface as an xdg_popup parent">
289+ This assigns an xdg_popup's parent to this layer_surface. This popup
290+ should have been created via xdg_surface::get_popup with the parent set
291+ to NULL, and this request must be invoked before committing the popup's
292+ initial state.
293+
294+ See the documentation of xdg_popup for more details about what an
295+ xdg_popup is and how it is used.
296+ </description>
297+ <arg name="popup" type="object" interface="xdg_popup"/>
298+ </request>
299+
300+ <request name="ack_configure">
301+ <description summary="ack a configure event">
302+ When a configure event is received, if a client commits the
303+ surface in response to the configure event, then the client
304+ must make an ack_configure request sometime before the commit
305+ request, passing along the serial of the configure event.
306+
307+ If the client receives multiple configure events before it
308+ can respond to one, it only has to ack the last configure event.
309+
310+ A client is not required to commit immediately after sending
311+ an ack_configure request - it may even ack_configure several times
312+ before its next surface commit.
313+
314+ A client may send multiple ack_configure requests before committing, but
315+ only the last request sent before a commit indicates which configure
316+ event the client really is responding to.
317+ </description>
318+ <arg name="serial" type="uint" summary="the serial from the configure event"/>
319+ </request>
320+
321+ <request name="destroy" type="destructor">
322+ <description summary="destroy the layer_surface">
323+ This request destroys the layer surface.
324+ </description>
325+ </request>
326+
327+ <event name="configure">
328+ <description summary="suggest a surface change">
329+ The configure event asks the client to resize its surface.
330+
331+ Clients should arrange their surface for the new states, and then send
332+ an ack_configure request with the serial sent in this configure event at
333+ some point before committing the new surface.
334+
335+ The client is free to dismiss all but the last configure event it
336+ received.
337+
338+ The width and height arguments specify the size of the window in
339+ surface-local coordinates.
340+
341+ The size is a hint, in the sense that the client is free to ignore it if
342+ it doesn't resize, pick a smaller size (to satisfy aspect ratio or
343+ resize in steps of NxM pixels). If the client picks a smaller size and
344+ is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the
345+ surface will be centered on this axis.
346+
347+ If the width or height arguments are zero, it means the client should
348+ decide its own window dimension.
349+ </description>
350+ <arg name="serial" type="uint"/>
351+ <arg name="width" type="uint"/>
352+ <arg name="height" type="uint"/>
353+ </event>
354+
355+ <event name="closed">
356+ <description summary="surface should be closed">
357+ The closed event is sent by the compositor when the surface will no
358+ longer be shown. The output may have been destroyed or the user may
359+ have asked for it to be removed. Further changes to the surface will be
360+ ignored. The client should destroy the resource after receiving this
361+ event, and create a new surface if they so choose.
362+ </description>
363+ </event>
364+
365+ <enum name="error">
366+ <entry name="invalid_surface_state" value="0" summary="provided surface state is invalid"/>
367+ <entry name="invalid_size" value="1" summary="size is invalid"/>
368+ <entry name="invalid_anchor" value="2" summary="anchor bitfield is invalid"/>
369+ <entry name="invalid_keyboard_interactivity" value="3" summary="keyboard interactivity is invalid"/>
370+ <entry name="invalid_exclusive_edge" value="4" summary="exclusive edge is invalid given the surface anchors"/>
371+ </enum>
372+
373+ <enum name="anchor" bitfield="true">
374+ <entry name="top" value="1" summary="the top edge of the anchor rectangle"/>
375+ <entry name="bottom" value="2" summary="the bottom edge of the anchor rectangle"/>
376+ <entry name="left" value="4" summary="the left edge of the anchor rectangle"/>
377+ <entry name="right" value="8" summary="the right edge of the anchor rectangle"/>
378+ </enum>
379+
380+ <!-- Version 2 additions -->
381+
382+ <request name="set_layer" since="2">
383+ <description summary="change the layer of the surface">
384+ Change the layer that the surface is rendered on.
385+
386+ Layer is double-buffered, see wl_surface.commit.
387+ </description>
388+ <arg name="layer" type="uint" enum="zwlr_layer_shell_v1.layer" summary="layer to move this surface to"/>
389+ </request>
390+
391+ <!-- Version 5 additions -->
392+
393+ <request name="set_exclusive_edge" since="5">
394+ <description summary="set the edge the exclusive zone will be applied to">
395+ Requests an edge for the exclusive zone to apply. The exclusive
396+ edge will be automatically deduced from anchor points when possible,
397+ but when the surface is anchored to a corner, it will be necessary
398+ to set it explicitly to disambiguate, as it is not possible to deduce
399+ which one of the two corner edges should be used.
400+
401+ The edge must be one the surface is anchored to, otherwise the
402+ invalid_exclusive_edge protocol error will be raised.
403+ </description>
404+ <arg name="edge" type="uint" enum="anchor"/>
405+ </request>
406+ </interface>
407+</protocol>
@@ -0,0 +1,407 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<protocol name="wlr_layer_shell_unstable_v1">
3+ <copyright>
4+ Copyright © 2017 Drew DeVault
5+
6+ Permission to use, copy, modify, distribute, and sell this
7+ software and its documentation for any purpose is hereby granted
8+ without fee, provided that the above copyright notice appear in
9+ all copies and that both that copyright notice and this permission
10+ notice appear in supporting documentation, and that the name of
11+ the copyright holders not be used in advertising or publicity
12+ pertaining to distribution of the software without specific,
13+ written prior permission. The copyright holders make no
14+ representations about the suitability of this software for any
15+ purpose. It is provided "as is" without express or implied
16+ warranty.
17+
18+ THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
19+ SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
20+ FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
21+ SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
22+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
23+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
24+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
25+ THIS SOFTWARE.
26+ </copyright>
27+
28+ <interface name="zwlr_layer_shell_v1" version="5">
29+ <description summary="create surfaces that are layers of the desktop">
30+ Clients can use this interface to assign the surface_layer role to
31+ wl_surfaces. Such surfaces are assigned to a "layer" of the output and
32+ rendered with a defined z-depth respective to each other. They may also be
33+ anchored to the edges and corners of a screen and specify input handling
34+ semantics. This interface should be suitable for the implementation of
35+ many desktop shell components, and a broad number of other applications
36+ that interact with the desktop.
37+ </description>
38+
39+ <request name="get_layer_surface">
40+ <description summary="create a layer_surface from a surface">
41+ Create a layer surface for an existing surface. This assigns the role of
42+ layer_surface, or raises a protocol error if another role is already
43+ assigned.
44+
45+ Creating a layer surface from a wl_surface which has a buffer attached
46+ or committed is a client error, and any attempts by a client to attach
47+ or manipulate a buffer prior to the first layer_surface.configure call
48+ must also be treated as errors.
49+
50+ After creating a layer_surface object and setting it up, the client
51+ must perform an initial commit without any buffer attached.
52+ The compositor will reply with a layer_surface.configure event.
53+ The client must acknowledge it and is then allowed to attach a buffer
54+ to map the surface.
55+
56+ You may pass NULL for output to allow the compositor to decide which
57+ output to use. Generally this will be the one that the user most
58+ recently interacted with.
59+
60+ Clients can specify a namespace that defines the purpose of the layer
61+ surface.
62+ </description>
63+ <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
64+ <arg name="surface" type="object" interface="wl_surface"/>
65+ <arg name="output" type="object" interface="wl_output" allow-null="true"/>
66+ <arg name="layer" type="uint" enum="layer" summary="layer to add this surface to"/>
67+ <arg name="namespace" type="string" summary="namespace for the layer surface"/>
68+ </request>
69+
70+ <enum name="error">
71+ <entry name="role" value="0" summary="wl_surface has another role"/>
72+ <entry name="invalid_layer" value="1" summary="layer value is invalid"/>
73+ <entry name="already_constructed" value="2" summary="wl_surface has a buffer attached or committed"/>
74+ </enum>
75+
76+ <enum name="layer">
77+ <description summary="available layers for surfaces">
78+ These values indicate which layers a surface can be rendered in. They
79+ are ordered by z depth, bottom-most first. Traditional shell surfaces
80+ will typically be rendered between the bottom and top layers.
81+ Fullscreen shell surfaces are typically rendered at the top layer.
82+ Multiple surfaces can share a single layer, and ordering within a
83+ single layer is undefined.
84+ </description>
85+
86+ <entry name="background" value="0"/>
87+ <entry name="bottom" value="1"/>
88+ <entry name="top" value="2"/>
89+ <entry name="overlay" value="3"/>
90+ </enum>
91+
92+ <!-- Version 3 additions -->
93+
94+ <request name="destroy" type="destructor" since="3">
95+ <description summary="destroy the layer_shell object">
96+ This request indicates that the client will not use the layer_shell
97+ object any more. Objects that have been created through this instance
98+ are not affected.
99+ </description>
100+ </request>
101+ </interface>
102+
103+ <interface name="zwlr_layer_surface_v1" version="5">
104+ <description summary="layer metadata interface">
105+ An interface that may be implemented by a wl_surface, for surfaces that
106+ are designed to be rendered as a layer of a stacked desktop-like
107+ environment.
108+
109+ Layer surface state (layer, size, anchor, exclusive zone,
110+ margin, interactivity) is double-buffered, and will be applied at the
111+ time wl_surface.commit of the corresponding wl_surface is called.
112+
113+ Attaching a null buffer to a layer surface unmaps it.
114+
115+ Unmapping a layer_surface means that the surface cannot be shown by the
116+ compositor until it is explicitly mapped again. The layer_surface
117+ returns to the state it had right after layer_shell.get_layer_surface.
118+ The client can re-map the surface by performing a commit without any
119+ buffer attached, waiting for a configure event and handling it as usual.
120+ </description>
121+
122+ <request name="set_size">
123+ <description summary="sets the size of the surface">
124+ Sets the size of the surface in surface-local coordinates. The
125+ compositor will display the surface centered with respect to its
126+ anchors.
127+
128+ If you pass 0 for either value, the compositor will assign it and
129+ inform you of the assignment in the configure event. You must set your
130+ anchor to opposite edges in the dimensions you omit; not doing so is a
131+ protocol error. Both values are 0 by default.
132+
133+ Size is double-buffered, see wl_surface.commit.
134+ </description>
135+ <arg name="width" type="uint"/>
136+ <arg name="height" type="uint"/>
137+ </request>
138+
139+ <request name="set_anchor">
140+ <description summary="configures the anchor point of the surface">
141+ Requests that the compositor anchor the surface to the specified edges
142+ and corners. If two orthogonal edges are specified (e.g. 'top' and
143+ 'left'), then the anchor point will be the intersection of the edges
144+ (e.g. the top left corner of the output); otherwise the anchor point
145+ will be centered on that edge, or in the center if none is specified.
146+
147+ Anchor is double-buffered, see wl_surface.commit.
148+ </description>
149+ <arg name="anchor" type="uint" enum="anchor"/>
150+ </request>
151+
152+ <request name="set_exclusive_zone">
153+ <description summary="configures the exclusive geometry of this surface">
154+ Requests that the compositor avoids occluding an area with other
155+ surfaces. The compositor's use of this information is
156+ implementation-dependent - do not assume that this region will not
157+ actually be occluded.
158+
159+ A positive value is only meaningful if the surface is anchored to one
160+ edge or an edge and both perpendicular edges. If the surface is not
161+ anchored, anchored to only two perpendicular edges (a corner), anchored
162+ to only two parallel edges or anchored to all edges, a positive value
163+ will be treated the same as zero.
164+
165+ A positive zone is the distance from the edge in surface-local
166+ coordinates to consider exclusive.
167+
168+ Surfaces that do not wish to have an exclusive zone may instead specify
169+ how they should interact with surfaces that do. If set to zero, the
170+ surface indicates that it would like to be moved to avoid occluding
171+ surfaces with a positive exclusive zone. If set to -1, the surface
172+ indicates that it would not like to be moved to accommodate for other
173+ surfaces, and the compositor should extend it all the way to the edges
174+ it is anchored to.
175+
176+ For example, a panel might set its exclusive zone to 10, so that
177+ maximized shell surfaces are not shown on top of it. A notification
178+ might set its exclusive zone to 0, so that it is moved to avoid
179+ occluding the panel, but shell surfaces are shown underneath it. A
180+ wallpaper or lock screen might set their exclusive zone to -1, so that
181+ they stretch below or over the panel.
182+
183+ The default value is 0.
184+
185+ Exclusive zone is double-buffered, see wl_surface.commit.
186+ </description>
187+ <arg name="zone" type="int"/>
188+ </request>
189+
190+ <request name="set_margin">
191+ <description summary="sets a margin from the anchor point">
192+ Requests that the surface be placed some distance away from the anchor
193+ point on the output, in surface-local coordinates. Setting this value
194+ for edges you are not anchored to has no effect.
195+
196+ The exclusive zone includes the margin.
197+
198+ Margin is double-buffered, see wl_surface.commit.
199+ </description>
200+ <arg name="top" type="int"/>
201+ <arg name="right" type="int"/>
202+ <arg name="bottom" type="int"/>
203+ <arg name="left" type="int"/>
204+ </request>
205+
206+ <enum name="keyboard_interactivity">
207+ <description summary="types of keyboard interaction possible for a layer shell surface">
208+ Types of keyboard interaction possible for layer shell surfaces. The
209+ rationale for this is twofold: (1) some applications are not interested
210+ in keyboard events and not allowing them to be focused can improve the
211+ desktop experience; (2) some applications will want to take exclusive
212+ keyboard focus.
213+ </description>
214+
215+ <entry name="none" value="0">
216+ <description summary="no keyboard focus is possible">
217+ This value indicates that this surface is not interested in keyboard
218+ events and the compositor should never assign it the keyboard focus.
219+
220+ This is the default value, set for newly created layer shell surfaces.
221+
222+ This is useful for e.g. desktop widgets that display information or
223+ only have interaction with non-keyboard input devices.
224+ </description>
225+ </entry>
226+ <entry name="exclusive" value="1">
227+ <description summary="request exclusive keyboard focus">
228+ Request exclusive keyboard focus if this surface is above the shell surface layer.
229+
230+ For the top and overlay layers, the seat will always give
231+ exclusive keyboard focus to the top-most layer which has keyboard
232+ interactivity set to exclusive. If this layer contains multiple
233+ surfaces with keyboard interactivity set to exclusive, the compositor
234+ determines the one receiving keyboard events in an implementation-
235+ defined manner. In this case, no guarantee is made when this surface
236+ will receive keyboard focus (if ever).
237+
238+ For the bottom and background layers, the compositor is allowed to use
239+ normal focus semantics.
240+
241+ This setting is mainly intended for applications that need to ensure
242+ they receive all keyboard events, such as a lock screen or a password
243+ prompt.
244+ </description>
245+ </entry>
246+ <entry name="on_demand" value="2" since="4">
247+ <description summary="request regular keyboard focus semantics">
248+ This requests the compositor to allow this surface to be focused and
249+ unfocused by the user in an implementation-defined manner. The user
250+ should be able to unfocus this surface even regardless of the layer
251+ it is on.
252+
253+ Typically, the compositor will want to use its normal mechanism to
254+ manage keyboard focus between layer shell surfaces with this setting
255+ and regular toplevels on the desktop layer (e.g. click to focus).
256+ Nevertheless, it is possible for a compositor to require a special
257+ interaction to focus or unfocus layer shell surfaces (e.g. requiring
258+ a click even if focus follows the mouse normally, or providing a
259+ keybinding to switch focus between layers).
260+
261+ This setting is mainly intended for desktop shell components (e.g.
262+ panels) that allow keyboard interaction. Using this option can allow
263+ implementing a desktop shell that can be fully usable without the
264+ mouse.
265+ </description>
266+ </entry>
267+ </enum>
268+
269+ <request name="set_keyboard_interactivity">
270+ <description summary="requests keyboard events">
271+ Set how keyboard events are delivered to this surface. By default,
272+ layer shell surfaces do not receive keyboard events; this request can
273+ be used to change this.
274+
275+ This setting is inherited by child surfaces set by the get_popup
276+ request.
277+
278+ Layer surfaces receive pointer, touch, and tablet events normally. If
279+ you do not want to receive them, set the input region on your surface
280+ to an empty region.
281+
282+ Keyboard interactivity is double-buffered, see wl_surface.commit.
283+ </description>
284+ <arg name="keyboard_interactivity" type="uint" enum="keyboard_interactivity"/>
285+ </request>
286+
287+ <request name="get_popup">
288+ <description summary="assign this layer_surface as an xdg_popup parent">
289+ This assigns an xdg_popup's parent to this layer_surface. This popup
290+ should have been created via xdg_surface::get_popup with the parent set
291+ to NULL, and this request must be invoked before committing the popup's
292+ initial state.
293+
294+ See the documentation of xdg_popup for more details about what an
295+ xdg_popup is and how it is used.
296+ </description>
297+ <arg name="popup" type="object" interface="xdg_popup"/>
298+ </request>
299+
300+ <request name="ack_configure">
301+ <description summary="ack a configure event">
302+ When a configure event is received, if a client commits the
303+ surface in response to the configure event, then the client
304+ must make an ack_configure request sometime before the commit
305+ request, passing along the serial of the configure event.
306+
307+ If the client receives multiple configure events before it
308+ can respond to one, it only has to ack the last configure event.
309+
310+ A client is not required to commit immediately after sending
311+ an ack_configure request - it may even ack_configure several times
312+ before its next surface commit.
313+
314+ A client may send multiple ack_configure requests before committing, but
315+ only the last request sent before a commit indicates which configure
316+ event the client really is responding to.
317+ </description>
318+ <arg name="serial" type="uint" summary="the serial from the configure event"/>
319+ </request>
320+
321+ <request name="destroy" type="destructor">
322+ <description summary="destroy the layer_surface">
323+ This request destroys the layer surface.
324+ </description>
325+ </request>
326+
327+ <event name="configure">
328+ <description summary="suggest a surface change">
329+ The configure event asks the client to resize its surface.
330+
331+ Clients should arrange their surface for the new states, and then send
332+ an ack_configure request with the serial sent in this configure event at
333+ some point before committing the new surface.
334+
335+ The client is free to dismiss all but the last configure event it
336+ received.
337+
338+ The width and height arguments specify the size of the window in
339+ surface-local coordinates.
340+
341+ The size is a hint, in the sense that the client is free to ignore it if
342+ it doesn't resize, pick a smaller size (to satisfy aspect ratio or
343+ resize in steps of NxM pixels). If the client picks a smaller size and
344+ is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the
345+ surface will be centered on this axis.
346+
347+ If the width or height arguments are zero, it means the client should
348+ decide its own window dimension.
349+ </description>
350+ <arg name="serial" type="uint"/>
351+ <arg name="width" type="uint"/>
352+ <arg name="height" type="uint"/>
353+ </event>
354+
355+ <event name="closed">
356+ <description summary="surface should be closed">
357+ The closed event is sent by the compositor when the surface will no
358+ longer be shown. The output may have been destroyed or the user may
359+ have asked for it to be removed. Further changes to the surface will be
360+ ignored. The client should destroy the resource after receiving this
361+ event, and create a new surface if they so choose.
362+ </description>
363+ </event>
364+
365+ <enum name="error">
366+ <entry name="invalid_surface_state" value="0" summary="provided surface state is invalid"/>
367+ <entry name="invalid_size" value="1" summary="size is invalid"/>
368+ <entry name="invalid_anchor" value="2" summary="anchor bitfield is invalid"/>
369+ <entry name="invalid_keyboard_interactivity" value="3" summary="keyboard interactivity is invalid"/>
370+ <entry name="invalid_exclusive_edge" value="4" summary="exclusive edge is invalid given the surface anchors"/>
371+ </enum>
372+
373+ <enum name="anchor" bitfield="true">
374+ <entry name="top" value="1" summary="the top edge of the anchor rectangle"/>
375+ <entry name="bottom" value="2" summary="the bottom edge of the anchor rectangle"/>
376+ <entry name="left" value="4" summary="the left edge of the anchor rectangle"/>
377+ <entry name="right" value="8" summary="the right edge of the anchor rectangle"/>
378+ </enum>
379+
380+ <!-- Version 2 additions -->
381+
382+ <request name="set_layer" since="2">
383+ <description summary="change the layer of the surface">
384+ Change the layer that the surface is rendered on.
385+
386+ Layer is double-buffered, see wl_surface.commit.
387+ </description>
388+ <arg name="layer" type="uint" enum="zwlr_layer_shell_v1.layer" summary="layer to move this surface to"/>
389+ </request>
390+
391+ <!-- Version 5 additions -->
392+
393+ <request name="set_exclusive_edge" since="5">
394+ <description summary="set the edge the exclusive zone will be applied to">
395+ Requests an edge for the exclusive zone to apply. The exclusive
396+ edge will be automatically deduced from anchor points when possible,
397+ but when the surface is anchored to a corner, it will be necessary
398+ to set it explicitly to disambiguate, as it is not possible to deduce
399+ which one of the two corner edges should be used.
400+
401+ The edge must be one the surface is anchored to, otherwise the
402+ invalid_exclusive_edge protocol error will be raised.
403+ </description>
404+ <arg name="edge" type="uint" enum="anchor"/>
405+ </request>
406+ </interface>
407+</protocol>
A internal/platform/wayland/layershell/wlr-layer-shell-unstable-v1_generated.go +311 -0
@@ -0,0 +1,311 @@
1+// Code generated by wlturbo-scanner. DO NOT EDIT.
2+// Protocol: wlr_layer_shell_unstable_v1
3+// Source: wlr-layer-shell-unstable-v1.xml
4+
5+package layershell
6+
7+import (
8+ "sync"
9+
10+ cross_wl_output "github.com/bnema/wlturbo/protocol/core"
11+ cross_wl_surface "github.com/bnema/wlturbo/protocol/core"
12+ cross_xdg_popup "github.com/bnema/wlturbo/protocol/xdgshell"
13+ "github.com/bnema/wlturbo/wl"
14+)
15+
16+// ERROR_ROLE is the error role value.
17+const ERROR_ROLE = 0
18+
19+// ERROR_INVALID_LAYER is the error invalid_layer value.
20+const ERROR_INVALID_LAYER = 1
21+
22+// ERROR_ALREADY_CONSTRUCTED is the error already_constructed value.
23+const ERROR_ALREADY_CONSTRUCTED = 2
24+
25+// LAYER_BACKGROUND is the layer background value.
26+const LAYER_BACKGROUND = 0
27+
28+// LAYER_BOTTOM is the layer bottom value.
29+const LAYER_BOTTOM = 1
30+
31+// LAYER_TOP is the layer top value.
32+const LAYER_TOP = 2
33+
34+// LAYER_OVERLAY is the layer overlay value.
35+const LAYER_OVERLAY = 3
36+
37+// KEYBOARD_INTERACTIVITY_NONE is the keyboard_interactivity none value.
38+const KEYBOARD_INTERACTIVITY_NONE = 0
39+
40+// KEYBOARD_INTERACTIVITY_EXCLUSIVE is the keyboard_interactivity exclusive value.
41+const KEYBOARD_INTERACTIVITY_EXCLUSIVE = 1
42+
43+// KEYBOARD_INTERACTIVITY_ON_DEMAND is the keyboard_interactivity on_demand value.
44+const KEYBOARD_INTERACTIVITY_ON_DEMAND = 2
45+
46+// ERROR_INVALID_SURFACE_STATE is the error invalid_surface_state value.
47+const ERROR_INVALID_SURFACE_STATE = 0
48+
49+// ERROR_INVALID_SIZE is the error invalid_size value.
50+const ERROR_INVALID_SIZE = 1
51+
52+// ERROR_INVALID_ANCHOR is the error invalid_anchor value.
53+const ERROR_INVALID_ANCHOR = 2
54+
55+// ERROR_INVALID_KEYBOARD_INTERACTIVITY is the error invalid_keyboard_interactivity value.
56+const ERROR_INVALID_KEYBOARD_INTERACTIVITY = 3
57+
58+// ERROR_INVALID_EXCLUSIVE_EDGE is the error invalid_exclusive_edge value.
59+const ERROR_INVALID_EXCLUSIVE_EDGE = 4
60+
61+// ANCHOR_TOP is the anchor top value.
62+const ANCHOR_TOP = 1
63+
64+// ANCHOR_BOTTOM is the anchor bottom value.
65+const ANCHOR_BOTTOM = 2
66+
67+// ANCHOR_LEFT is the anchor left value.
68+const ANCHOR_LEFT = 4
69+
70+// ANCHOR_RIGHT is the anchor right value.
71+const ANCHOR_RIGHT = 8
72+
73+// LayerShellInterface is the protocol interface name.
74+const LayerShellInterface = "zwlr_layer_shell_v1"
75+
76+// LayerShell is the generated binding for zwlr_layer_shell_v1 version 5.
77+type LayerShell struct {
78+ wl.BaseProxy
79+}
80+
81+// NewLayerShell returns an unbound LayerShell.
82+func NewLayerShell(ctx *wl.Context) *LayerShell {
83+ obj := &LayerShell{}
84+ obj.SetContext(ctx)
85+ ctx.Register(obj)
86+ return obj
87+}
88+
89+// Create a layer surface for an existing surface. This assigns the role of layer_surface, or raises a protocol error if another role is already assigned. Creating a layer surface from a wl_surface which has a buffer attached or committed is a client error, and any attempts by a client to attach or manipulate a buffer prior to the first layer_surface.configure call must also be treated as errors. After creating a layer_surface object and setting it up, the client must perform an initial commit without any buffer attached. The compositor will reply with a layer_surface.configure event. The client must acknowledge it and is then allowed to attach a buffer to map the surface. You may pass NULL for output to allow the compositor to decide which output to use. Generally this will be the one that the user most recently interacted with. Clients can specify a namespace that defines the purpose of the layer surface.
90+func (o *LayerShell) GetLayerSurface(surface *cross_wl_surface.Surface, output *cross_wl_output.Output, layer uint32, namespace string) (*LayerSurface, error) {
91+ var arg1 wl.Object
92+ if surface != nil {
93+ arg1 = surface
94+ }
95+ var arg2 wl.Object
96+ if output != nil {
97+ arg2 = output
98+ }
99+ child := &LayerSurface{}
100+ child.SetContext(o.Context())
101+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 0, Name: "zwlr_layer_shell_v1.get_layer_surface", Child: child}, child, arg1, arg2, layer, namespace); err != nil {
102+ return nil, err
103+ }
104+ return child, nil
105+}
106+
107+// This request indicates that the client will not use the layer_shell object any more. Objects that have been created through this instance are not affected.
108+func (o *LayerShell) Destroy() error {
109+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 1, Name: "zwlr_layer_shell_v1.destroy", Since: 3, Destructor: true}); err != nil {
110+ return err
111+ }
112+ return nil
113+}
114+
115+// EventSignature returns the declared event argument types in wire order.
116+func (o *LayerShell) EventSignature(opcode uint16) (string, bool) {
117+ switch opcode {
118+ default:
119+ return "", false
120+ }
121+}
122+
123+// Dispatch decodes one event on zwlr_layer_shell_v1 and calls the registered handlers.
124+func (o *LayerShell) Dispatch(event *wl.Event) {
125+ switch event.Opcode {
126+ default:
127+ }
128+}
129+
130+// LayerSurfaceInterface is the protocol interface name.
131+const LayerSurfaceInterface = "zwlr_layer_surface_v1"
132+
133+// LayerSurface is the generated binding for zwlr_layer_surface_v1 version 5.
134+type LayerSurface struct {
135+ wl.BaseProxy
136+ // handlersMu guards the handler slices: a client may register or replace
137+ // handlers while another goroutine dispatches events.
138+ handlersMu sync.Mutex
139+ onConfigure []func(serial uint32, width uint32, height uint32)
140+ onClosed []func()
141+}
142+
143+// NewLayerSurface returns an unbound LayerSurface.
144+func NewLayerSurface(ctx *wl.Context) *LayerSurface {
145+ obj := &LayerSurface{}
146+ obj.SetContext(ctx)
147+ ctx.Register(obj)
148+ return obj
149+}
150+
151+// Sets the size of the surface in surface-local coordinates. The compositor will display the surface centered with respect to its anchors. If you pass 0 for either value, the compositor will assign it and inform you of the assignment in the configure event. You must set your anchor to opposite edges in the dimensions you omit; not doing so is a protocol error. Both values are 0 by default. Size is double-buffered, see wl_surface.commit.
152+func (o *LayerSurface) SetSize(width uint32, height uint32) error {
153+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 0, Name: "zwlr_layer_surface_v1.set_size"}, width, height); err != nil {
154+ return err
155+ }
156+ return nil
157+}
158+
159+// Requests that the compositor anchor the surface to the specified edges and corners. If two orthogonal edges are specified (e.g. 'top' and 'left'), then the anchor point will be the intersection of the edges (e.g. the top left corner of the output); otherwise the anchor point will be centered on that edge, or in the center if none is specified. Anchor is double-buffered, see wl_surface.commit.
160+func (o *LayerSurface) SetAnchor(anchor uint32) error {
161+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 1, Name: "zwlr_layer_surface_v1.set_anchor"}, anchor); err != nil {
162+ return err
163+ }
164+ return nil
165+}
166+
167+// Requests that the compositor avoids occluding an area with other surfaces. The compositor's use of this information is implementation-dependent - do not assume that this region will not actually be occluded. A positive value is only meaningful if the surface is anchored to one edge or an edge and both perpendicular edges. If the surface is not anchored, anchored to only two perpendicular edges (a corner), anchored to only two parallel edges or anchored to all edges, a positive value will be treated the same as zero. A positive zone is the distance from the edge in surface-local coordinates to consider exclusive. Surfaces that do not wish to have an exclusive zone may instead specify how they should interact with surfaces that do. If set to zero, the surface indicates that it would like to be moved to avoid occluding surfaces with a positive exclusive zone. If set to -1, the surface indicates that it would not like to be moved to accommodate for other surfaces, and the compositor should extend it all the way to the edges it is anchored to. For example, a panel might set its exclusive zone to 10, so that maximized shell surfaces are not shown on top of it. A notification might set its exclusive zone to 0, so that it is moved to avoid occluding the panel, but shell surfaces are shown underneath it. A wallpaper or lock screen might set their exclusive zone to -1, so that they stretch below or over the panel. The default value is 0. Exclusive zone is double-buffered, see wl_surface.commit.
168+func (o *LayerSurface) SetExclusiveZone(zone int32) error {
169+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 2, Name: "zwlr_layer_surface_v1.set_exclusive_zone"}, zone); err != nil {
170+ return err
171+ }
172+ return nil
173+}
174+
175+// Requests that the surface be placed some distance away from the anchor point on the output, in surface-local coordinates. Setting this value for edges you are not anchored to has no effect. The exclusive zone includes the margin. Margin is double-buffered, see wl_surface.commit.
176+func (o *LayerSurface) SetMargin(top int32, right int32, bottom int32, left int32) error {
177+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 3, Name: "zwlr_layer_surface_v1.set_margin"}, top, right, bottom, left); err != nil {
178+ return err
179+ }
180+ return nil
181+}
182+
183+// Set how keyboard events are delivered to this surface. By default, layer shell surfaces do not receive keyboard events; this request can be used to change this. This setting is inherited by child surfaces set by the get_popup request. Layer surfaces receive pointer, touch, and tablet events normally. If you do not want to receive them, set the input region on your surface to an empty region. Keyboard interactivity is double-buffered, see wl_surface.commit.
184+func (o *LayerSurface) SetKeyboardInteractivity(keyboardInteractivity uint32) error {
185+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 4, Name: "zwlr_layer_surface_v1.set_keyboard_interactivity"}, keyboardInteractivity); err != nil {
186+ return err
187+ }
188+ return nil
189+}
190+
191+// This assigns an xdg_popup's parent to this layer_surface. This popup should have been created via xdg_surface::get_popup with the parent set to NULL, and this request must be invoked before committing the popup's initial state. See the documentation of xdg_popup for more details about what an xdg_popup is and how it is used.
192+func (o *LayerSurface) GetPopup(popup *cross_xdg_popup.XdgPopup) error {
193+ var arg0 wl.Object
194+ if popup != nil {
195+ arg0 = popup
196+ }
197+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 5, Name: "zwlr_layer_surface_v1.get_popup"}, arg0); err != nil {
198+ return err
199+ }
200+ return nil
201+}
202+
203+// When a configure event is received, if a client commits the surface in response to the configure event, then the client must make an ack_configure request sometime before the commit request, passing along the serial of the configure event. If the client receives multiple configure events before it can respond to one, it only has to ack the last configure event. A client is not required to commit immediately after sending an ack_configure request - it may even ack_configure several times before its next surface commit. A client may send multiple ack_configure requests before committing, but only the last request sent before a commit indicates which configure event the client really is responding to.
204+func (o *LayerSurface) AckConfigure(serial uint32) error {
205+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 6, Name: "zwlr_layer_surface_v1.ack_configure"}, serial); err != nil {
206+ return err
207+ }
208+ return nil
209+}
210+
211+// This request destroys the layer surface.
212+func (o *LayerSurface) Destroy() error {
213+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 7, Name: "zwlr_layer_surface_v1.destroy", Destructor: true}); err != nil {
214+ return err
215+ }
216+ return nil
217+}
218+
219+// Change the layer that the surface is rendered on. Layer is double-buffered, see wl_surface.commit.
220+func (o *LayerSurface) SetLayer(layer uint32) error {
221+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 8, Name: "zwlr_layer_surface_v1.set_layer", Since: 2}, layer); err != nil {
222+ return err
223+ }
224+ return nil
225+}
226+
227+// Requests an edge for the exclusive zone to apply. The exclusive edge will be automatically deduced from anchor points when possible, but when the surface is anchored to a corner, it will be necessary to set it explicitly to disambiguate, as it is not possible to deduce which one of the two corner edges should be used. The edge must be one the surface is anchored to, otherwise the invalid_exclusive_edge protocol error will be raised.
228+func (o *LayerSurface) SetExclusiveEdge(edge uint32) error {
229+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 9, Name: "zwlr_layer_surface_v1.set_exclusive_edge", Since: 5}, edge); err != nil {
230+ return err
231+ }
232+ return nil
233+}
234+
235+// OnConfigure registers a handler for the configure event.
236+//
237+// Handlers are appended, so a second call adds another handler rather than
238+// replacing the first. Registration is safe while another goroutine dispatches.
239+func (o *LayerSurface) OnConfigure(handler func(serial uint32, width uint32, height uint32)) {
240+ if handler == nil {
241+ return
242+ }
243+ o.handlersMu.Lock()
244+ defer o.handlersMu.Unlock()
245+ o.onConfigure = append(o.onConfigure, handler)
246+}
247+
248+// handlersForConfigure returns the handlers registered for configure, taken
249+// under the lock so a handler may register another handler while events are
250+// being dispatched.
251+func (o *LayerSurface) handlersForConfigure() []func(serial uint32, width uint32, height uint32) {
252+ o.handlersMu.Lock()
253+ defer o.handlersMu.Unlock()
254+ return o.onConfigure
255+}
256+
257+// OnClosed registers a handler for the closed event.
258+//
259+// Handlers are appended, so a second call adds another handler rather than
260+// replacing the first. Registration is safe while another goroutine dispatches.
261+func (o *LayerSurface) OnClosed(handler func()) {
262+ if handler == nil {
263+ return
264+ }
265+ o.handlersMu.Lock()
266+ defer o.handlersMu.Unlock()
267+ o.onClosed = append(o.onClosed, handler)
268+}
269+
270+// handlersForClosed returns the handlers registered for closed, taken
271+// under the lock so a handler may register another handler while events are
272+// being dispatched.
273+func (o *LayerSurface) handlersForClosed() []func() {
274+ o.handlersMu.Lock()
275+ defer o.handlersMu.Unlock()
276+ return o.onClosed
277+}
278+
279+// EventSignature returns the declared event argument types in wire order.
280+func (o *LayerSurface) EventSignature(opcode uint16) (string, bool) {
281+ switch opcode {
282+ case 0:
283+ return "uint,uint,uint,", true
284+ case 1:
285+ return "", true
286+ default:
287+ return "", false
288+ }
289+}
290+
291+// Dispatch decodes one event on zwlr_layer_surface_v1 and calls the registered handlers.
292+func (o *LayerSurface) Dispatch(event *wl.Event) {
293+ switch event.Opcode {
294+ case 0: // configure
295+ serial := event.Uint32()
296+ width := event.Uint32()
297+ height := event.Uint32()
298+
299+ for i, handler := range o.handlersForConfigure() {
300+ _ = i
301+ handler(serial, width, height)
302+ }
303+ case 1: // closed
304+
305+ for i, handler := range o.handlersForClosed() {
306+ _ = i
307+ handler()
308+ }
309+ default:
310+ }
311+}
@@ -0,0 +1,311 @@
1+// Code generated by wlturbo-scanner. DO NOT EDIT.
2+// Protocol: wlr_layer_shell_unstable_v1
3+// Source: wlr-layer-shell-unstable-v1.xml
4+
5+package layershell
6+
7+import (
8+ "sync"
9+
10+ cross_wl_output "github.com/bnema/wlturbo/protocol/core"
11+ cross_wl_surface "github.com/bnema/wlturbo/protocol/core"
12+ cross_xdg_popup "github.com/bnema/wlturbo/protocol/xdgshell"
13+ "github.com/bnema/wlturbo/wl"
14+)
15+
16+// ERROR_ROLE is the error role value.
17+const ERROR_ROLE = 0
18+
19+// ERROR_INVALID_LAYER is the error invalid_layer value.
20+const ERROR_INVALID_LAYER = 1
21+
22+// ERROR_ALREADY_CONSTRUCTED is the error already_constructed value.
23+const ERROR_ALREADY_CONSTRUCTED = 2
24+
25+// LAYER_BACKGROUND is the layer background value.
26+const LAYER_BACKGROUND = 0
27+
28+// LAYER_BOTTOM is the layer bottom value.
29+const LAYER_BOTTOM = 1
30+
31+// LAYER_TOP is the layer top value.
32+const LAYER_TOP = 2
33+
34+// LAYER_OVERLAY is the layer overlay value.
35+const LAYER_OVERLAY = 3
36+
37+// KEYBOARD_INTERACTIVITY_NONE is the keyboard_interactivity none value.
38+const KEYBOARD_INTERACTIVITY_NONE = 0
39+
40+// KEYBOARD_INTERACTIVITY_EXCLUSIVE is the keyboard_interactivity exclusive value.
41+const KEYBOARD_INTERACTIVITY_EXCLUSIVE = 1
42+
43+// KEYBOARD_INTERACTIVITY_ON_DEMAND is the keyboard_interactivity on_demand value.
44+const KEYBOARD_INTERACTIVITY_ON_DEMAND = 2
45+
46+// ERROR_INVALID_SURFACE_STATE is the error invalid_surface_state value.
47+const ERROR_INVALID_SURFACE_STATE = 0
48+
49+// ERROR_INVALID_SIZE is the error invalid_size value.
50+const ERROR_INVALID_SIZE = 1
51+
52+// ERROR_INVALID_ANCHOR is the error invalid_anchor value.
53+const ERROR_INVALID_ANCHOR = 2
54+
55+// ERROR_INVALID_KEYBOARD_INTERACTIVITY is the error invalid_keyboard_interactivity value.
56+const ERROR_INVALID_KEYBOARD_INTERACTIVITY = 3
57+
58+// ERROR_INVALID_EXCLUSIVE_EDGE is the error invalid_exclusive_edge value.
59+const ERROR_INVALID_EXCLUSIVE_EDGE = 4
60+
61+// ANCHOR_TOP is the anchor top value.
62+const ANCHOR_TOP = 1
63+
64+// ANCHOR_BOTTOM is the anchor bottom value.
65+const ANCHOR_BOTTOM = 2
66+
67+// ANCHOR_LEFT is the anchor left value.
68+const ANCHOR_LEFT = 4
69+
70+// ANCHOR_RIGHT is the anchor right value.
71+const ANCHOR_RIGHT = 8
72+
73+// LayerShellInterface is the protocol interface name.
74+const LayerShellInterface = "zwlr_layer_shell_v1"
75+
76+// LayerShell is the generated binding for zwlr_layer_shell_v1 version 5.
77+type LayerShell struct {
78+ wl.BaseProxy
79+}
80+
81+// NewLayerShell returns an unbound LayerShell.
82+func NewLayerShell(ctx *wl.Context) *LayerShell {
83+ obj := &LayerShell{}
84+ obj.SetContext(ctx)
85+ ctx.Register(obj)
86+ return obj
87+}
88+
89+// Create a layer surface for an existing surface. This assigns the role of layer_surface, or raises a protocol error if another role is already assigned. Creating a layer surface from a wl_surface which has a buffer attached or committed is a client error, and any attempts by a client to attach or manipulate a buffer prior to the first layer_surface.configure call must also be treated as errors. After creating a layer_surface object and setting it up, the client must perform an initial commit without any buffer attached. The compositor will reply with a layer_surface.configure event. The client must acknowledge it and is then allowed to attach a buffer to map the surface. You may pass NULL for output to allow the compositor to decide which output to use. Generally this will be the one that the user most recently interacted with. Clients can specify a namespace that defines the purpose of the layer surface.
90+func (o *LayerShell) GetLayerSurface(surface *cross_wl_surface.Surface, output *cross_wl_output.Output, layer uint32, namespace string) (*LayerSurface, error) {
91+ var arg1 wl.Object
92+ if surface != nil {
93+ arg1 = surface
94+ }
95+ var arg2 wl.Object
96+ if output != nil {
97+ arg2 = output
98+ }
99+ child := &LayerSurface{}
100+ child.SetContext(o.Context())
101+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 0, Name: "zwlr_layer_shell_v1.get_layer_surface", Child: child}, child, arg1, arg2, layer, namespace); err != nil {
102+ return nil, err
103+ }
104+ return child, nil
105+}
106+
107+// This request indicates that the client will not use the layer_shell object any more. Objects that have been created through this instance are not affected.
108+func (o *LayerShell) Destroy() error {
109+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 1, Name: "zwlr_layer_shell_v1.destroy", Since: 3, Destructor: true}); err != nil {
110+ return err
111+ }
112+ return nil
113+}
114+
115+// EventSignature returns the declared event argument types in wire order.
116+func (o *LayerShell) EventSignature(opcode uint16) (string, bool) {
117+ switch opcode {
118+ default:
119+ return "", false
120+ }
121+}
122+
123+// Dispatch decodes one event on zwlr_layer_shell_v1 and calls the registered handlers.
124+func (o *LayerShell) Dispatch(event *wl.Event) {
125+ switch event.Opcode {
126+ default:
127+ }
128+}
129+
130+// LayerSurfaceInterface is the protocol interface name.
131+const LayerSurfaceInterface = "zwlr_layer_surface_v1"
132+
133+// LayerSurface is the generated binding for zwlr_layer_surface_v1 version 5.
134+type LayerSurface struct {
135+ wl.BaseProxy
136+ // handlersMu guards the handler slices: a client may register or replace
137+ // handlers while another goroutine dispatches events.
138+ handlersMu sync.Mutex
139+ onConfigure []func(serial uint32, width uint32, height uint32)
140+ onClosed []func()
141+}
142+
143+// NewLayerSurface returns an unbound LayerSurface.
144+func NewLayerSurface(ctx *wl.Context) *LayerSurface {
145+ obj := &LayerSurface{}
146+ obj.SetContext(ctx)
147+ ctx.Register(obj)
148+ return obj
149+}
150+
151+// Sets the size of the surface in surface-local coordinates. The compositor will display the surface centered with respect to its anchors. If you pass 0 for either value, the compositor will assign it and inform you of the assignment in the configure event. You must set your anchor to opposite edges in the dimensions you omit; not doing so is a protocol error. Both values are 0 by default. Size is double-buffered, see wl_surface.commit.
152+func (o *LayerSurface) SetSize(width uint32, height uint32) error {
153+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 0, Name: "zwlr_layer_surface_v1.set_size"}, width, height); err != nil {
154+ return err
155+ }
156+ return nil
157+}
158+
159+// Requests that the compositor anchor the surface to the specified edges and corners. If two orthogonal edges are specified (e.g. 'top' and 'left'), then the anchor point will be the intersection of the edges (e.g. the top left corner of the output); otherwise the anchor point will be centered on that edge, or in the center if none is specified. Anchor is double-buffered, see wl_surface.commit.
160+func (o *LayerSurface) SetAnchor(anchor uint32) error {
161+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 1, Name: "zwlr_layer_surface_v1.set_anchor"}, anchor); err != nil {
162+ return err
163+ }
164+ return nil
165+}
166+
167+// Requests that the compositor avoids occluding an area with other surfaces. The compositor's use of this information is implementation-dependent - do not assume that this region will not actually be occluded. A positive value is only meaningful if the surface is anchored to one edge or an edge and both perpendicular edges. If the surface is not anchored, anchored to only two perpendicular edges (a corner), anchored to only two parallel edges or anchored to all edges, a positive value will be treated the same as zero. A positive zone is the distance from the edge in surface-local coordinates to consider exclusive. Surfaces that do not wish to have an exclusive zone may instead specify how they should interact with surfaces that do. If set to zero, the surface indicates that it would like to be moved to avoid occluding surfaces with a positive exclusive zone. If set to -1, the surface indicates that it would not like to be moved to accommodate for other surfaces, and the compositor should extend it all the way to the edges it is anchored to. For example, a panel might set its exclusive zone to 10, so that maximized shell surfaces are not shown on top of it. A notification might set its exclusive zone to 0, so that it is moved to avoid occluding the panel, but shell surfaces are shown underneath it. A wallpaper or lock screen might set their exclusive zone to -1, so that they stretch below or over the panel. The default value is 0. Exclusive zone is double-buffered, see wl_surface.commit.
168+func (o *LayerSurface) SetExclusiveZone(zone int32) error {
169+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 2, Name: "zwlr_layer_surface_v1.set_exclusive_zone"}, zone); err != nil {
170+ return err
171+ }
172+ return nil
173+}
174+
175+// Requests that the surface be placed some distance away from the anchor point on the output, in surface-local coordinates. Setting this value for edges you are not anchored to has no effect. The exclusive zone includes the margin. Margin is double-buffered, see wl_surface.commit.
176+func (o *LayerSurface) SetMargin(top int32, right int32, bottom int32, left int32) error {
177+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 3, Name: "zwlr_layer_surface_v1.set_margin"}, top, right, bottom, left); err != nil {
178+ return err
179+ }
180+ return nil
181+}
182+
183+// Set how keyboard events are delivered to this surface. By default, layer shell surfaces do not receive keyboard events; this request can be used to change this. This setting is inherited by child surfaces set by the get_popup request. Layer surfaces receive pointer, touch, and tablet events normally. If you do not want to receive them, set the input region on your surface to an empty region. Keyboard interactivity is double-buffered, see wl_surface.commit.
184+func (o *LayerSurface) SetKeyboardInteractivity(keyboardInteractivity uint32) error {
185+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 4, Name: "zwlr_layer_surface_v1.set_keyboard_interactivity"}, keyboardInteractivity); err != nil {
186+ return err
187+ }
188+ return nil
189+}
190+
191+// This assigns an xdg_popup's parent to this layer_surface. This popup should have been created via xdg_surface::get_popup with the parent set to NULL, and this request must be invoked before committing the popup's initial state. See the documentation of xdg_popup for more details about what an xdg_popup is and how it is used.
192+func (o *LayerSurface) GetPopup(popup *cross_xdg_popup.XdgPopup) error {
193+ var arg0 wl.Object
194+ if popup != nil {
195+ arg0 = popup
196+ }
197+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 5, Name: "zwlr_layer_surface_v1.get_popup"}, arg0); err != nil {
198+ return err
199+ }
200+ return nil
201+}
202+
203+// When a configure event is received, if a client commits the surface in response to the configure event, then the client must make an ack_configure request sometime before the commit request, passing along the serial of the configure event. If the client receives multiple configure events before it can respond to one, it only has to ack the last configure event. A client is not required to commit immediately after sending an ack_configure request - it may even ack_configure several times before its next surface commit. A client may send multiple ack_configure requests before committing, but only the last request sent before a commit indicates which configure event the client really is responding to.
204+func (o *LayerSurface) AckConfigure(serial uint32) error {
205+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 6, Name: "zwlr_layer_surface_v1.ack_configure"}, serial); err != nil {
206+ return err
207+ }
208+ return nil
209+}
210+
211+// This request destroys the layer surface.
212+func (o *LayerSurface) Destroy() error {
213+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 7, Name: "zwlr_layer_surface_v1.destroy", Destructor: true}); err != nil {
214+ return err
215+ }
216+ return nil
217+}
218+
219+// Change the layer that the surface is rendered on. Layer is double-buffered, see wl_surface.commit.
220+func (o *LayerSurface) SetLayer(layer uint32) error {
221+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 8, Name: "zwlr_layer_surface_v1.set_layer", Since: 2}, layer); err != nil {
222+ return err
223+ }
224+ return nil
225+}
226+
227+// Requests an edge for the exclusive zone to apply. The exclusive edge will be automatically deduced from anchor points when possible, but when the surface is anchored to a corner, it will be necessary to set it explicitly to disambiguate, as it is not possible to deduce which one of the two corner edges should be used. The edge must be one the surface is anchored to, otherwise the invalid_exclusive_edge protocol error will be raised.
228+func (o *LayerSurface) SetExclusiveEdge(edge uint32) error {
229+ if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 9, Name: "zwlr_layer_surface_v1.set_exclusive_edge", Since: 5}, edge); err != nil {
230+ return err
231+ }
232+ return nil
233+}
234+
235+// OnConfigure registers a handler for the configure event.
236+//
237+// Handlers are appended, so a second call adds another handler rather than
238+// replacing the first. Registration is safe while another goroutine dispatches.
239+func (o *LayerSurface) OnConfigure(handler func(serial uint32, width uint32, height uint32)) {
240+ if handler == nil {
241+ return
242+ }
243+ o.handlersMu.Lock()
244+ defer o.handlersMu.Unlock()
245+ o.onConfigure = append(o.onConfigure, handler)
246+}
247+
248+// handlersForConfigure returns the handlers registered for configure, taken
249+// under the lock so a handler may register another handler while events are
250+// being dispatched.
251+func (o *LayerSurface) handlersForConfigure() []func(serial uint32, width uint32, height uint32) {
252+ o.handlersMu.Lock()
253+ defer o.handlersMu.Unlock()
254+ return o.onConfigure
255+}
256+
257+// OnClosed registers a handler for the closed event.
258+//
259+// Handlers are appended, so a second call adds another handler rather than
260+// replacing the first. Registration is safe while another goroutine dispatches.
261+func (o *LayerSurface) OnClosed(handler func()) {
262+ if handler == nil {
263+ return
264+ }
265+ o.handlersMu.Lock()
266+ defer o.handlersMu.Unlock()
267+ o.onClosed = append(o.onClosed, handler)
268+}
269+
270+// handlersForClosed returns the handlers registered for closed, taken
271+// under the lock so a handler may register another handler while events are
272+// being dispatched.
273+func (o *LayerSurface) handlersForClosed() []func() {
274+ o.handlersMu.Lock()
275+ defer o.handlersMu.Unlock()
276+ return o.onClosed
277+}
278+
279+// EventSignature returns the declared event argument types in wire order.
280+func (o *LayerSurface) EventSignature(opcode uint16) (string, bool) {
281+ switch opcode {
282+ case 0:
283+ return "uint,uint,uint,", true
284+ case 1:
285+ return "", true
286+ default:
287+ return "", false
288+ }
289+}
290+
291+// Dispatch decodes one event on zwlr_layer_surface_v1 and calls the registered handlers.
292+func (o *LayerSurface) Dispatch(event *wl.Event) {
293+ switch event.Opcode {
294+ case 0: // configure
295+ serial := event.Uint32()
296+ width := event.Uint32()
297+ height := event.Uint32()
298+
299+ for i, handler := range o.handlersForConfigure() {
300+ _ = i
301+ handler(serial, width, height)
302+ }
303+ case 1: // closed
304+
305+ for i, handler := range o.handlersForClosed() {
306+ _ = i
307+ handler()
308+ }
309+ default:
310+ }
311+}
M internal/platform/wayland/window.go +144 -43
@@ -6,12 +6,17 @@
66 package wayland
77
88 import (
9+ "context"
910 "encoding/binary"
1011 "errors"
1112 "fmt"
1213 "math"
14+ "net"
15+ "os"
16+ "path/filepath"
1317 "sync"
1418 "sync/atomic"
19+ "time"
1520
1621 "github.com/bnema/wlturbo"
1722 "github.com/bnema/wlturbo/protocol/core"
@@ -22,6 +27,8 @@ import (
2227 "github.com/bnema/wlturbo/protocol/viewporter"
2328 "github.com/bnema/wlturbo/protocol/xdgshell"
2429 "github.com/bnema/wlturbo/wl"
30+
31+ "github.com/bnema/nefergui/internal/platform/wayland/layershell"
2532 )
2633
2734 // CapabilityError reports a missing or insufficient required protocol.
@@ -46,6 +53,8 @@ type Window struct {
4653 Shell *xdgshell.XdgWmBase
4754 Dmabuf *linuxdmabuf.LinuxDmabuf
4855 SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager
56+ LayerShell *layershell.LayerShell // nil for xdg windows
57+ LayerSurface *layershell.LayerSurface // nil for xdg windows
4958 Surface *core.Surface
5059 XdgSurface *xdgshell.XdgSurface
5160 Toplevel *xdgshell.XdgToplevel
@@ -79,28 +88,94 @@ type Window struct {
7988 readerStarted atomic.Bool
8089 inputAdapter *InputAdapter
8190 inputQueue []InputEvent
91+ // inputRects/inputCustom record the current input region; session loop only.
92+ inputRects []Rect
93+ inputCustom bool
94+ layerOutputGlobal uint32
95+ layerOutputSelected bool
8296 // InputOverflow counts events discarded when the 4096-event queue fills.
8397 InputOverflow uint64
8498 }
8599
86-func Connect(name string, width, height int32, transparent bool) (_ *Window, err error) {
100+// Connect opens a default xdg-toplevel window with full-surface input. It
101+// cannot be cancelled; use ConnectWithOptions for a cancellable startup.
102+func Connect(name string, width, height int32, transparent bool) (*Window, error) {
103+ return ConnectWithOptions(context.Background(), name, width, height, transparent, SurfaceOptions{})
104+}
105+
106+// dialTimeout bounds the unix-socket dial; a local dial is normally instant.
107+const dialTimeout = 5 * time.Second
108+
109+// waylandSocketPath resolves name like wlturbo.Connect does.
110+func waylandSocketPath(name string) (string, error) {
111+ if name == "" {
112+ name = os.Getenv("WAYLAND_DISPLAY")
113+ if name == "" {
114+ name = "wayland-0"
115+ }
116+ }
117+ if filepath.IsAbs(name) {
118+ return name, nil
119+ }
120+ runDir := os.Getenv("XDG_RUNTIME_DIR")
121+ if runDir == "" {
122+ return "", errors.New("XDG_RUNTIME_DIR not set")
123+ }
124+ return filepath.Join(runDir, name), nil
125+}
126+
127+// ConnectWithOptions opens a window whose role and initial input region are
128+// selected by opts. The zero SurfaceOptions is identical to Connect. Startup
129+// (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the
130+// connection is closed and the returned error wraps ctx.Err(). Once it
131+// returns successfully, ctx no longer affects the window.
132+func ConnectWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts SurfaceOptions) (res *Window, err error) {
133+ if ctx == nil {
134+ return nil, errors.New("nil context")
135+ }
87136 if width <= 0 || height <= 0 {
88137 return nil, fmt.Errorf("invalid logical size %dx%d", width, height)
89138 }
90- d, err := wl.Connect(name)
139+ if err = opts.Validate(); err != nil {
140+ return nil, err
141+ }
142+ if err = ctx.Err(); err != nil {
143+ return nil, err
144+ }
145+ path, err := waylandSocketPath(name)
91146 if err != nil {
92147 return nil, err
93148 }
149+ dialer := net.Dialer{Timeout: dialTimeout}
150+ conn, err := dialer.DialContext(ctx, "unix", path)
151+ if err != nil {
152+ return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
153+ }
154+ d, err := wlturbo.ConnectFromConn(conn)
155+ if err != nil {
156+ return nil, err
157+ }
158+ // Closing the display unblocks any pending read; Display.Close is idempotent.
159+ stopWatch := context.AfterFunc(ctx, func() { _ = d.Close() })
94160 w := &Window{events: make(chan Event, 128), readerStop: make(chan struct{}), Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent}
95161 defer func() {
162+ if !stopWatch() && err == nil {
163+ // Cancelled after the last step but before return: the watcher owns a
164+ // closed display, so the window is unusable.
165+ err = ctx.Err()
166+ }
96167 if err != nil {
168+ if cerr := ctx.Err(); cerr != nil && !errors.Is(err, cerr) {
169+ err = errors.Join(cerr, err)
170+ }
97171 _ = w.Close()
172+ res = nil
98173 }
99174 }()
100175 if err = d.Roundtrip(); err != nil {
101176 return nil, err
102177 }
103- ctx := d.Context()
178+ wctx := d.Context()
104179 bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error {
105180 advertised, ok := d.Registry().FindGlobal(name)
106181 if !ok || advertised.Version < minimum {
@@ -115,54 +190,62 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err
115190 }
116191 return nil
117192 }
118- w.Compositor = core.NewCompositor(ctx)
193+ w.Compositor = core.NewCompositor(wctx)
119194 if err = bind(core.CompositorInterface, 6, 4, w.Compositor); err != nil {
120195 return nil, err
121196 }
122- w.Shell = xdgshell.NewXdgWmBase(ctx)
123- if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil {
124- return nil, err
197+ if opts.Layer != nil {
198+ if err = w.bindLayerShell(bind); err != nil {
199+ return nil, err
200+ }
201+ } else {
202+ w.Shell = xdgshell.NewXdgWmBase(wctx)
203+ if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil {
204+ return nil, err
205+ }
125206 }
126- w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(ctx)
207+ w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(wctx)
127208 if err = bind(linuxdmabuf.LinuxDmabufInterface, 4, 4, w.Dmabuf); err != nil {
128209 return nil, err
129210 }
130- w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(ctx)
211+ w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(wctx)
131212 if err = bind(drmsyncobj.WpLinuxDrmSyncobjManagerInterface, 1, 1, w.SyncManager); err != nil {
132213 return nil, err
133214 }
134- w.Viewporter = viewporter.NewWpViewporter(ctx)
215+ w.Viewporter = viewporter.NewWpViewporter(wctx)
135216 if _, e := d.Registry().BindNegotiated(viewporter.WpViewporterInterface, 1, w.Viewporter); e != nil {
136217 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
137218 return nil, e
138219 }
139- ctx.Unregister(w.Viewporter)
220+ wctx.Unregister(w.Viewporter)
140221 w.Viewporter = nil
141222 }
142- w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(ctx)
223+ w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(wctx)
143224 if _, e := d.Registry().BindNegotiated(fractionalscale.WpFractionalScaleManagerInterface, 1, w.ScaleManager); e != nil {
144225 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
145226 return nil, e
146227 }
147- ctx.Unregister(w.ScaleManager)
228+ wctx.Unregister(w.ScaleManager)
148229 w.ScaleManager = nil
149230 }
150- w.CursorShapes = cursorshape.NewWpCursorShapeManager(ctx)
231+ w.CursorShapes = cursorshape.NewWpCursorShapeManager(wctx)
151232 if _, e := d.Registry().BindNegotiated(cursorshape.WpCursorShapeManagerInterface, 1, w.CursorShapes); e != nil {
152233 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
153234 return nil, e
154235 }
155- ctx.Unregister(w.CursorShapes)
236+ wctx.Unregister(w.CursorShapes)
156237 w.CursorShapes = nil
157238 }
158239 if err = w.BindSeat(); err != nil {
159240 return nil, err
160241 }
161- w.Shell.OnPing(func(serial uint32) {
162- if e := w.Shell.Pong(serial); e != nil {
163- w.feedbackFailure(e)
164- }
165- })
242+ if w.Shell != nil {
243+ w.Shell.OnPing(func(serial uint32) {
244+ if e := w.Shell.Pong(serial); e != nil {
245+ w.feedbackFailure(e)
246+ }
247+ })
248+ }
166249 w.Surface, err = w.Compositor.CreateSurface()
167250 if err != nil {
168251 return nil, err
@@ -207,14 +290,51 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err
207290 w.Dirty = true
208291 }
209292 })
210- w.XdgSurface, err = w.Shell.GetXdgSurface(w.Surface)
293+ if opts.Layer != nil {
294+ if err = w.createLayerSurface(*opts.Layer); err != nil {
295+ return nil, err
296+ }
297+ } else if err = w.createToplevel(); err != nil {
298+ return nil, err
299+ }
300+ if opts.InputRects != nil {
301+ if err = w.SetInputRects(opts.InputRects); err != nil {
302+ return nil, err
303+ }
304+ }
305+ w.SyncSurface, err = w.SyncManager.GetSurface(w.Surface)
211306 if err != nil {
212307 return nil, err
213308 }
214- w.Toplevel, err = w.XdgSurface.GetToplevel()
309+ w.Feedback, err = w.Dmabuf.GetDefaultFeedback()
215310 if err != nil {
216311 return nil, err
217312 }
313+ w.watchFeedback()
314+ if err = w.Surface.Commit(); err != nil {
315+ return nil, err
316+ } // no buffer before configure
317+ for !w.Configured || w.MainDevice == 0 || len(w.Tranches) == 0 {
318+ if w.Closed {
319+ return nil, fmt.Errorf("window closed before initial configure and feedback")
320+ }
321+ if err = w.Dispatch(); err != nil {
322+ return nil, err
323+ }
324+ }
325+ return w, nil
326+}
327+
328+func (w *Window) createToplevel() error {
329+ var err error
330+ w.XdgSurface, err = w.Shell.GetXdgSurface(w.Surface)
331+ if err != nil {
332+ return err
333+ }
334+ w.Toplevel, err = w.XdgSurface.GetToplevel()
335+ if err != nil {
336+ return err
337+ }
218338 w.Toplevel.OnClose(func() {
219339 if w.readerStarted.Load() {
220340 w.post(Event{Kind: CloseEvent})
@@ -242,27 +362,7 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err
242362 }
243363 w.Configured, w.FrameReady = true, true
244364 })
245- w.SyncSurface, err = w.SyncManager.GetSurface(w.Surface)
246- if err != nil {
247- return nil, err
248- }
249- w.Feedback, err = w.Dmabuf.GetDefaultFeedback()
250- if err != nil {
251- return nil, err
252- }
253- w.watchFeedback()
254- if err = w.Surface.Commit(); err != nil {
255- return nil, err
256- } // no buffer before configure
257- for !w.Configured || w.MainDevice == 0 || len(w.Tranches) == 0 {
258- if w.Closed {
259- return nil, fmt.Errorf("window closed before initial configure and feedback")
260- }
261- if err = w.Dispatch(); err != nil {
262- return nil, err
263- }
264- }
265- return w, nil
365+ return nil
266366 }
267367
268368 func (w *Window) Dispatch() error {
@@ -399,6 +499,7 @@ func (w *Window) Close() error {
399499 _ = w.SyncSurface.Destroy()
400500 w.SyncSurface = nil
401501 }
502+ w.closeLayer() // before wl_surface
402503 if w.Toplevel != nil {
403504 _ = w.Toplevel.Destroy()
404505 w.Toplevel = nil
@@ -6,12 +6,17 @@
6 package wayland6 package wayland
7 7
8 import (8 import (
9+ "context"
9 "encoding/binary"10 "encoding/binary"
10 "errors"11 "errors"
11 "fmt"12 "fmt"
12 "math"13 "math"
14+ "net"
15+ "os"
16+ "path/filepath"
13 "sync"17 "sync"
14 "sync/atomic"18 "sync/atomic"
19+ "time"
15 20
16 "github.com/bnema/wlturbo"21 "github.com/bnema/wlturbo"
17 "github.com/bnema/wlturbo/protocol/core"22 "github.com/bnema/wlturbo/protocol/core"
@@ -22,6 +27,8 @@ import (
22 "github.com/bnema/wlturbo/protocol/viewporter"27 "github.com/bnema/wlturbo/protocol/viewporter"
23 "github.com/bnema/wlturbo/protocol/xdgshell"28 "github.com/bnema/wlturbo/protocol/xdgshell"
24 "github.com/bnema/wlturbo/wl"29 "github.com/bnema/wlturbo/wl"
30+
31+ "github.com/bnema/nefergui/internal/platform/wayland/layershell"
25 )32 )
26 33
27 // CapabilityError reports a missing or insufficient required protocol.34 // CapabilityError reports a missing or insufficient required protocol.
@@ -46,6 +53,8 @@ type Window struct {
46 Shell *xdgshell.XdgWmBase53 Shell *xdgshell.XdgWmBase
47 Dmabuf *linuxdmabuf.LinuxDmabuf54 Dmabuf *linuxdmabuf.LinuxDmabuf
48 SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager55 SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager
56+ LayerShell *layershell.LayerShell // nil for xdg windows
57+ LayerSurface *layershell.LayerSurface // nil for xdg windows
49 Surface *core.Surface58 Surface *core.Surface
50 XdgSurface *xdgshell.XdgSurface59 XdgSurface *xdgshell.XdgSurface
51 Toplevel *xdgshell.XdgToplevel60 Toplevel *xdgshell.XdgToplevel
@@ -79,28 +88,94 @@ type Window struct {
79 readerStarted atomic.Bool88 readerStarted atomic.Bool
80 inputAdapter *InputAdapter89 inputAdapter *InputAdapter
81 inputQueue []InputEvent90 inputQueue []InputEvent
91+ // inputRects/inputCustom record the current input region; session loop only.
92+ inputRects []Rect
93+ inputCustom bool
94+ layerOutputGlobal uint32
95+ layerOutputSelected bool
82 // InputOverflow counts events discarded when the 4096-event queue fills.96 // InputOverflow counts events discarded when the 4096-event queue fills.
83 InputOverflow uint6497 InputOverflow uint64
84 }98 }
85 99
86-func Connect(name string, width, height int32, transparent bool) (_ *Window, err error) {100+// Connect opens a default xdg-toplevel window with full-surface input. It
101+// cannot be cancelled; use ConnectWithOptions for a cancellable startup.
102+func Connect(name string, width, height int32, transparent bool) (*Window, error) {
103+ return ConnectWithOptions(context.Background(), name, width, height, transparent, SurfaceOptions{})
104+}
105+
106+// dialTimeout bounds the unix-socket dial; a local dial is normally instant.
107+const dialTimeout = 5 * time.Second
108+
109+// waylandSocketPath resolves name like wlturbo.Connect does.
110+func waylandSocketPath(name string) (string, error) {
111+ if name == "" {
112+ name = os.Getenv("WAYLAND_DISPLAY")
113+ if name == "" {
114+ name = "wayland-0"
115+ }
116+ }
117+ if filepath.IsAbs(name) {
118+ return name, nil
119+ }
120+ runDir := os.Getenv("XDG_RUNTIME_DIR")
121+ if runDir == "" {
122+ return "", errors.New("XDG_RUNTIME_DIR not set")
123+ }
124+ return filepath.Join(runDir, name), nil
125+}
126+
127+// ConnectWithOptions opens a window whose role and initial input region are
128+// selected by opts. The zero SurfaceOptions is identical to Connect. Startup
129+// (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the
130+// connection is closed and the returned error wraps ctx.Err(). Once it
131+// returns successfully, ctx no longer affects the window.
132+func ConnectWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts SurfaceOptions) (res *Window, err error) {
133+ if ctx == nil {
134+ return nil, errors.New("nil context")
135+ }
87 if width <= 0 || height <= 0 {136 if width <= 0 || height <= 0 {
88 return nil, fmt.Errorf("invalid logical size %dx%d", width, height)137 return nil, fmt.Errorf("invalid logical size %dx%d", width, height)
89 }138 }
90- d, err := wl.Connect(name)139+ if err = opts.Validate(); err != nil {
140+ return nil, err
141+ }
142+ if err = ctx.Err(); err != nil {
143+ return nil, err
144+ }
145+ path, err := waylandSocketPath(name)
91 if err != nil {146 if err != nil {
92 return nil, err147 return nil, err
93 }148 }
149+ dialer := net.Dialer{Timeout: dialTimeout}
150+ conn, err := dialer.DialContext(ctx, "unix", path)
151+ if err != nil {
152+ return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
153+ }
154+ d, err := wlturbo.ConnectFromConn(conn)
155+ if err != nil {
156+ return nil, err
157+ }
158+ // Closing the display unblocks any pending read; Display.Close is idempotent.
159+ stopWatch := context.AfterFunc(ctx, func() { _ = d.Close() })
94 w := &Window{events: make(chan Event, 128), readerStop: make(chan struct{}), Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent}160 w := &Window{events: make(chan Event, 128), readerStop: make(chan struct{}), Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent}
95 defer func() {161 defer func() {
162+ if !stopWatch() && err == nil {
163+ // Cancelled after the last step but before return: the watcher owns a
164+ // closed display, so the window is unusable.
165+ err = ctx.Err()
166+ }
96 if err != nil {167 if err != nil {
168+ if cerr := ctx.Err(); cerr != nil && !errors.Is(err, cerr) {
169+ err = errors.Join(cerr, err)
170+ }
97 _ = w.Close()171 _ = w.Close()
172+ res = nil
98 }173 }
99 }()174 }()
100 if err = d.Roundtrip(); err != nil {175 if err = d.Roundtrip(); err != nil {
101 return nil, err176 return nil, err
102 }177 }
103- ctx := d.Context()178+ wctx := d.Context()
104 bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error {179 bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error {
105 advertised, ok := d.Registry().FindGlobal(name)180 advertised, ok := d.Registry().FindGlobal(name)
106 if !ok || advertised.Version < minimum {181 if !ok || advertised.Version < minimum {
@@ -115,54 +190,62 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err
115 }190 }
116 return nil191 return nil
117 }192 }
118- w.Compositor = core.NewCompositor(ctx)193+ w.Compositor = core.NewCompositor(wctx)
119 if err = bind(core.CompositorInterface, 6, 4, w.Compositor); err != nil {194 if err = bind(core.CompositorInterface, 6, 4, w.Compositor); err != nil {
120 return nil, err195 return nil, err
121 }196 }
122- w.Shell = xdgshell.NewXdgWmBase(ctx)197+ if opts.Layer != nil {
123- if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil {198+ if err = w.bindLayerShell(bind); err != nil {
124- return nil, err199+ return nil, err
200+ }
201+ } else {
202+ w.Shell = xdgshell.NewXdgWmBase(wctx)
203+ if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil {
204+ return nil, err
205+ }
125 }206 }
126- w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(ctx)207+ w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(wctx)
127 if err = bind(linuxdmabuf.LinuxDmabufInterface, 4, 4, w.Dmabuf); err != nil {208 if err = bind(linuxdmabuf.LinuxDmabufInterface, 4, 4, w.Dmabuf); err != nil {
128 return nil, err209 return nil, err
129 }210 }
130- w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(ctx)211+ w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(wctx)
131 if err = bind(drmsyncobj.WpLinuxDrmSyncobjManagerInterface, 1, 1, w.SyncManager); err != nil {212 if err = bind(drmsyncobj.WpLinuxDrmSyncobjManagerInterface, 1, 1, w.SyncManager); err != nil {
132 return nil, err213 return nil, err
133 }214 }
134- w.Viewporter = viewporter.NewWpViewporter(ctx)215+ w.Viewporter = viewporter.NewWpViewporter(wctx)
135 if _, e := d.Registry().BindNegotiated(viewporter.WpViewporterInterface, 1, w.Viewporter); e != nil {216 if _, e := d.Registry().BindNegotiated(viewporter.WpViewporterInterface, 1, w.Viewporter); e != nil {
136 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {217 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
137 return nil, e218 return nil, e
138 }219 }
139- ctx.Unregister(w.Viewporter)220+ wctx.Unregister(w.Viewporter)
140 w.Viewporter = nil221 w.Viewporter = nil
141 }222 }
142- w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(ctx)223+ w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(wctx)
143 if _, e := d.Registry().BindNegotiated(fractionalscale.WpFractionalScaleManagerInterface, 1, w.ScaleManager); e != nil {224 if _, e := d.Registry().BindNegotiated(fractionalscale.WpFractionalScaleManagerInterface, 1, w.ScaleManager); e != nil {
144 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {225 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
145 return nil, e226 return nil, e
146 }227 }
147- ctx.Unregister(w.ScaleManager)228+ wctx.Unregister(w.ScaleManager)
148 w.ScaleManager = nil229 w.ScaleManager = nil
149 }230 }
150- w.CursorShapes = cursorshape.NewWpCursorShapeManager(ctx)231+ w.CursorShapes = cursorshape.NewWpCursorShapeManager(wctx)
151 if _, e := d.Registry().BindNegotiated(cursorshape.WpCursorShapeManagerInterface, 1, w.CursorShapes); e != nil {232 if _, e := d.Registry().BindNegotiated(cursorshape.WpCursorShapeManagerInterface, 1, w.CursorShapes); e != nil {
152 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {233 if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
153 return nil, e234 return nil, e
154 }235 }
155- ctx.Unregister(w.CursorShapes)236+ wctx.Unregister(w.CursorShapes)
156 w.CursorShapes = nil237 w.CursorShapes = nil
157 }238 }
158 if err = w.BindSeat(); err != nil {239 if err = w.BindSeat(); err != nil {
159 return nil, err240 return nil, err
160 }241 }
161- w.Shell.OnPing(func(serial uint32) {242+ if w.Shell != nil {
162- if e := w.Shell.Pong(serial); e != nil {243+ w.Shell.OnPing(func(serial uint32) {
163- w.feedbackFailure(e)244+ if e := w.Shell.Pong(serial); e != nil {
164- }245+ w.feedbackFailure(e)
165- })246+ }
247+ })
248+ }
166 w.Surface, err = w.Compositor.CreateSurface()249 w.Surface, err = w.Compositor.CreateSurface()
167 if err != nil {250 if err != nil {
168 return nil, err251 return nil, err
@@ -207,14 +290,51 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err
207 w.Dirty = true290 w.Dirty = true
208 }291 }
209 })292 })
210- w.XdgSurface, err = w.Shell.GetXdgSurface(w.Surface)293+ if opts.Layer != nil {
294+ if err = w.createLayerSurface(*opts.Layer); err != nil {
295+ return nil, err
296+ }
297+ } else if err = w.createToplevel(); err != nil {
298+ return nil, err
299+ }
300+ if opts.InputRects != nil {
301+ if err = w.SetInputRects(opts.InputRects); err != nil {
302+ return nil, err
303+ }
304+ }
305+ w.SyncSurface, err = w.SyncManager.GetSurface(w.Surface)
211 if err != nil {306 if err != nil {
212 return nil, err307 return nil, err
213 }308 }
214- w.Toplevel, err = w.XdgSurface.GetToplevel()309+ w.Feedback, err = w.Dmabuf.GetDefaultFeedback()
215 if err != nil {310 if err != nil {
216 return nil, err311 return nil, err
217 }312 }
313+ w.watchFeedback()
314+ if err = w.Surface.Commit(); err != nil {
315+ return nil, err
316+ } // no buffer before configure
317+ for !w.Configured || w.MainDevice == 0 || len(w.Tranches) == 0 {
318+ if w.Closed {
319+ return nil, fmt.Errorf("window closed before initial configure and feedback")
320+ }
321+ if err = w.Dispatch(); err != nil {
322+ return nil, err
323+ }
324+ }
325+ return w, nil
326+}
327+
328+func (w *Window) createToplevel() error {
329+ var err error
330+ w.XdgSurface, err = w.Shell.GetXdgSurface(w.Surface)
331+ if err != nil {
332+ return err
333+ }
334+ w.Toplevel, err = w.XdgSurface.GetToplevel()
335+ if err != nil {
336+ return err
337+ }
218 w.Toplevel.OnClose(func() {338 w.Toplevel.OnClose(func() {
219 if w.readerStarted.Load() {339 if w.readerStarted.Load() {
220 w.post(Event{Kind: CloseEvent})340 w.post(Event{Kind: CloseEvent})
@@ -242,27 +362,7 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err
242 }362 }
243 w.Configured, w.FrameReady = true, true363 w.Configured, w.FrameReady = true, true
244 })364 })
245- w.SyncSurface, err = w.SyncManager.GetSurface(w.Surface)365+ return nil
246- if err != nil {
247- return nil, err
248- }
249- w.Feedback, err = w.Dmabuf.GetDefaultFeedback()
250- if err != nil {
251- return nil, err
252- }
253- w.watchFeedback()
254- if err = w.Surface.Commit(); err != nil {
255- return nil, err
256- } // no buffer before configure
257- for !w.Configured || w.MainDevice == 0 || len(w.Tranches) == 0 {
258- if w.Closed {
259- return nil, fmt.Errorf("window closed before initial configure and feedback")
260- }
261- if err = w.Dispatch(); err != nil {
262- return nil, err
263- }
264- }
265- return w, nil
266 }366 }
267 367
268 func (w *Window) Dispatch() error {368 func (w *Window) Dispatch() error {
@@ -399,6 +499,7 @@ func (w *Window) Close() error {
399 _ = w.SyncSurface.Destroy()499 _ = w.SyncSurface.Destroy()
400 w.SyncSurface = nil500 w.SyncSurface = nil
401 }501 }
502+ w.closeLayer() // before wl_surface
402 if w.Toplevel != nil {503 if w.Toplevel != nil {
403 _ = w.Toplevel.Destroy()504 _ = w.Toplevel.Destroy()
404 w.Toplevel = nil505 w.Toplevel = nil
M internal/presentation/session/schedule_test.go +2 -1
@@ -48,7 +48,8 @@ func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
4848 t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))
4949 }
5050 // Retries resubmit the same prepared frame without redrawing.
51- if h.step() || h.step() || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
51+ firstRetry, secondRetry := h.step(), h.step()
52+ if firstRetry || secondRetry || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
5253 t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)
5354 }
5455 h.blocked = false
@@ -48,7 +48,8 @@ func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
48 t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))48 t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))
49 }49 }
50 // Retries resubmit the same prepared frame without redrawing.50 // Retries resubmit the same prepared frame without redrawing.
51- if h.step() || h.step() || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {51+ firstRetry, secondRetry := h.step(), h.step()
52+ if firstRetry || secondRetry || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
52 t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)53 t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)
53 }54 }
54 h.blocked = false55 h.blocked = false
M internal/presentation/session/session.go +10 -2
@@ -152,8 +152,16 @@ func (s *Session) traceState(b *buffers.Buffer, event string) {
152152 }{time.Now(), s.frame, b.ID, b.Generation, b.State, event, b.AcquirePoint, b.ReleasePoint, b.WlReleased, b.PointSignaled})
153153 }
154154
155-func Open(name string, width, height int32, transparent bool) (_ *Session, err error) {
156- w, err := wayland.Connect(name, width, height, transparent)
155+// Open opens a default xdg-toplevel session.
156+func Open(name string, width, height int32, transparent bool) (*Session, error) {
157+ return OpenWithOptions(context.Background(), name, width, height, transparent, wayland.SurfaceOptions{})
158+}
159+
160+// OpenWithOptions opens a session whose surface role and initial input region
161+// are selected by opts; the zero value is identical to Open. Wayland startup
162+// is abandoned when ctx is cancelled; ctx is not retained after it returns.
163+func OpenWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts wayland.SurfaceOptions) (_ *Session, err error) {
164+ w, err := wayland.ConnectWithOptions(ctx, name, width, height, transparent, opts)
157165 if err != nil {
158166 return nil, err
159167 }
@@ -152,8 +152,16 @@ func (s *Session) traceState(b *buffers.Buffer, event string) {
152 }{time.Now(), s.frame, b.ID, b.Generation, b.State, event, b.AcquirePoint, b.ReleasePoint, b.WlReleased, b.PointSignaled})152 }{time.Now(), s.frame, b.ID, b.Generation, b.State, event, b.AcquirePoint, b.ReleasePoint, b.WlReleased, b.PointSignaled})
153 }153 }
154 154
155-func Open(name string, width, height int32, transparent bool) (_ *Session, err error) {155+// Open opens a default xdg-toplevel session.
156- w, err := wayland.Connect(name, width, height, transparent)156+func Open(name string, width, height int32, transparent bool) (*Session, error) {
157+ return OpenWithOptions(context.Background(), name, width, height, transparent, wayland.SurfaceOptions{})
158+}
159+
160+// OpenWithOptions opens a session whose surface role and initial input region
161+// are selected by opts; the zero value is identical to Open. Wayland startup
162+// is abandoned when ctx is cancelled; ctx is not retained after it returns.
163+func OpenWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts wayland.SurfaceOptions) (_ *Session, err error) {
164+ w, err := wayland.ConnectWithOptions(ctx, name, width, height, transparent, opts)
157 if err != nil {165 if err != nil {
158 return nil, err166 return nil, err
159 }167 }
A internal/ui/headless_test.go +178 -0
@@ -0,0 +1,178 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/nefergui/internal/harness"
15+)
16+
17+// These tests drive the real Run loop against a headless NeferWL. They run only
18+// when NEFERGUI_NEFERWL points at a compositor build; the client is this test
19+// binary re-executed, since Run needs a compositor socket in its environment.
20+
21+const headlessClientEnv = "NEFERGUI_UI_HEADLESS_SCENARIO"
22+
23+func requireCompositor(t *testing.T) {
24+ t.Helper()
25+ if os.Getenv("NEFERGUI_NEFERWL") == "" {
26+ t.Skip("NEFERGUI_NEFERWL not set")
27+ }
28+}
29+
30+func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) []string {
31+ t.Helper()
32+ requireCompositor(t)
33+ dir := t.TempDir()
34+ events := filepath.Join(dir, "events.txt")
35+ input := ""
36+ if script != "" {
37+ input = filepath.Join(dir, "input")
38+ if err := os.WriteFile(input, []byte(script), 0600); err != nil {
39+ t.Fatal(err)
40+ }
41+ }
42+ t.Setenv(headlessClientEnv, scenario)
43+ t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events)
44+ res, code := harness.Run(context.Background(), harness.Options{
45+ Size: "640x480", Scale: "1", Layout: "us", Background: "#000000",
46+ Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true,
47+ Timeout: timeout, ReadyTimeout: 5 * time.Second,
48+ Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"},
49+ })
50+ data, _ := os.ReadFile(events)
51+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
52+ if code != harness.Pass {
53+ stderr, _ := os.ReadFile(res.Artifacts["client_stderr"])
54+ t.Fatalf("harness code %d: %s\nevents:\n%s\nstderr:\n%s", code, res.Error, data, stderr)
55+ }
56+ return lines
57+}
58+
59+func indexOf(lines []string, prefix string) int {
60+ for i, l := range lines {
61+ if strings.HasPrefix(l, prefix) {
62+ return i
63+ }
64+ }
65+ return -1
66+}
67+
68+func TestHeadlessLayerHooks(t *testing.T) {
69+ lines := runHeadless(t, "layer", "sleep 3s\nmove 5 5\nsleep 200ms\nclick left\nkey a\nkey Escape\nsleep 500ms\n", 30*time.Second)
70+ surface, view := indexOf(lines, "surface"), indexOf(lines, "view ")
71+ if surface != 0 || view < 0 || surface > view {
72+ t.Fatalf("OnSurface must run once, before the first view:\n%s", strings.Join(lines, "\n"))
73+ }
74+ count := 0
75+ for _, l := range lines {
76+ if l == "surface" {
77+ count++
78+ }
79+ }
80+ joined := "\n" + strings.Join(lines, "\n") + "\n"
81+ if count != 1 || strings.Count(joined, "\nresize ") != 1 || !strings.Contains(joined, "\nresize 640 480 1\n") {
82+ t.Fatalf("surface %d, resize must fire once with 640x480:\n%s", count, joined)
83+ }
84+ if w := indexOf(lines, "wake-sent"); w < 0 || indexOf(lines[w:], "view ") < 0 || indexOf(lines[w:], "view ") > indexOf(lines[w:], "input press") {
85+ t.Errorf("wake must produce a view before input starts:\n%s", joined)
86+ }
87+ for _, want := range []string{"\nview size 640x480", "\ninput press", "\ninput key Escape", "\nclicks 1\n", "\nexit context canceled\n"} {
88+ if !strings.Contains(joined, want) {
89+ t.Errorf("missing %q in:\n%s", want, joined)
90+ }
91+ }
92+}
93+
94+func TestHeadlessSurfaceHookErrorAborts(t *testing.T) {
95+ lines := runHeadless(t, "hook-error", "", 20*time.Second)
96+ joined := strings.Join(lines, "\n")
97+ if !strings.Contains(joined, "exit nefergui: surface hook: hook failed") || strings.Contains(joined, "view") {
98+ t.Fatalf("hook error must abort before any view:\n%s", joined)
99+ }
100+}
101+
102+func TestHeadlessSurfaceHookCancellation(t *testing.T) {
103+ lines := runHeadless(t, "hook-cancel", "", 20*time.Second)
104+ joined := strings.Join(lines, "\n")
105+ if !strings.Contains(joined, "exit context canceled") || strings.Contains(joined, "view") {
106+ t.Fatalf("cancellation during the hook must return ctx.Err before any view:\n%s", joined)
107+ }
108+}
109+
110+// TestHeadlessClientProcess is the client half; it does nothing under go test.
111+func TestHeadlessClientProcess(t *testing.T) {
112+ scenario := os.Getenv(headlessClientEnv)
113+ if scenario == "" {
114+ t.Skip("client process only")
115+ }
116+ out, err := os.Create(os.Getenv("NEFERGUI_UI_HEADLESS_OUT"))
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ var mu sync.Mutex
121+ logf := func(format string, args ...any) {
122+ mu.Lock()
123+ defer mu.Unlock()
124+ fmt.Fprintf(out, format+"\n", args...)
125+ }
126+ type model struct{ clicks int }
127+ m := &model{}
128+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
129+ defer cancel()
130+ view := func(f *Frame, m *model) {
131+ w, h := f.Size()
132+ logf("view size %vx%v", w, h)
133+ st := f.Root().Stack()
134+ st.Box(Key("box")).Rect(10, 20, 50, 40)
135+ if st.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
136+ m.clicks++
137+ logf("clicks %d", m.clicks)
138+ }
139+ }
140+ options := []WindowOption{Size(300, 200), Transparent()}
141+ switch scenario {
142+ case "layer":
143+ wake := make(chan struct{}, 1)
144+ // Nothing else redraws an idle window before input starts at 3s.
145+ go func() { time.Sleep(1500 * time.Millisecond); logf("wake-sent"); wake <- struct{}{} }()
146+ options = append(options,
147+ Layer(LayerConfig{Level: LayerOverlay, Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, Keyboard: KeyboardExclusive}),
148+ Wake(wake),
149+ OnSurface(func(context.Context, WaylandSurface) error { logf("surface"); return nil }),
150+ OnResize(func(w, h int, s float64) { logf("resize %d %d %v", w, h, s) }),
151+ OnInput(func(ev InputEvent) bool {
152+ switch {
153+ case ev.Kind == InputPointerPress:
154+ logf("input press")
155+ return true
156+ case ev.Kind == InputKey && ev.Pressed && ev.KeyName == "Escape":
157+ logf("input key Escape")
158+ cancel()
159+ }
160+ return false
161+ }),
162+ )
163+ case "hook-error":
164+ options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
165+ OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") }))
166+ case "hook-cancel":
167+ options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
168+ OnSurface(func(ctx context.Context, _ WaylandSurface) error {
169+ go func() { time.Sleep(200 * time.Millisecond); cancel() }()
170+ <-ctx.Done()
171+ return nil
172+ }))
173+ }
174+ err = Run(ctx, m, view, options...)
175+ logf("exit %v", err)
176+ out.Close()
177+ os.Exit(0)
178+}
@@ -0,0 +1,178 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/nefergui/internal/harness"
15+)
16+
17+// These tests drive the real Run loop against a headless NeferWL. They run only
18+// when NEFERGUI_NEFERWL points at a compositor build; the client is this test
19+// binary re-executed, since Run needs a compositor socket in its environment.
20+
21+const headlessClientEnv = "NEFERGUI_UI_HEADLESS_SCENARIO"
22+
23+func requireCompositor(t *testing.T) {
24+ t.Helper()
25+ if os.Getenv("NEFERGUI_NEFERWL") == "" {
26+ t.Skip("NEFERGUI_NEFERWL not set")
27+ }
28+}
29+
30+func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) []string {
31+ t.Helper()
32+ requireCompositor(t)
33+ dir := t.TempDir()
34+ events := filepath.Join(dir, "events.txt")
35+ input := ""
36+ if script != "" {
37+ input = filepath.Join(dir, "input")
38+ if err := os.WriteFile(input, []byte(script), 0600); err != nil {
39+ t.Fatal(err)
40+ }
41+ }
42+ t.Setenv(headlessClientEnv, scenario)
43+ t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events)
44+ res, code := harness.Run(context.Background(), harness.Options{
45+ Size: "640x480", Scale: "1", Layout: "us", Background: "#000000",
46+ Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true,
47+ Timeout: timeout, ReadyTimeout: 5 * time.Second,
48+ Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"},
49+ })
50+ data, _ := os.ReadFile(events)
51+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
52+ if code != harness.Pass {
53+ stderr, _ := os.ReadFile(res.Artifacts["client_stderr"])
54+ t.Fatalf("harness code %d: %s\nevents:\n%s\nstderr:\n%s", code, res.Error, data, stderr)
55+ }
56+ return lines
57+}
58+
59+func indexOf(lines []string, prefix string) int {
60+ for i, l := range lines {
61+ if strings.HasPrefix(l, prefix) {
62+ return i
63+ }
64+ }
65+ return -1
66+}
67+
68+func TestHeadlessLayerHooks(t *testing.T) {
69+ lines := runHeadless(t, "layer", "sleep 3s\nmove 5 5\nsleep 200ms\nclick left\nkey a\nkey Escape\nsleep 500ms\n", 30*time.Second)
70+ surface, view := indexOf(lines, "surface"), indexOf(lines, "view ")
71+ if surface != 0 || view < 0 || surface > view {
72+ t.Fatalf("OnSurface must run once, before the first view:\n%s", strings.Join(lines, "\n"))
73+ }
74+ count := 0
75+ for _, l := range lines {
76+ if l == "surface" {
77+ count++
78+ }
79+ }
80+ joined := "\n" + strings.Join(lines, "\n") + "\n"
81+ if count != 1 || strings.Count(joined, "\nresize ") != 1 || !strings.Contains(joined, "\nresize 640 480 1\n") {
82+ t.Fatalf("surface %d, resize must fire once with 640x480:\n%s", count, joined)
83+ }
84+ if w := indexOf(lines, "wake-sent"); w < 0 || indexOf(lines[w:], "view ") < 0 || indexOf(lines[w:], "view ") > indexOf(lines[w:], "input press") {
85+ t.Errorf("wake must produce a view before input starts:\n%s", joined)
86+ }
87+ for _, want := range []string{"\nview size 640x480", "\ninput press", "\ninput key Escape", "\nclicks 1\n", "\nexit context canceled\n"} {
88+ if !strings.Contains(joined, want) {
89+ t.Errorf("missing %q in:\n%s", want, joined)
90+ }
91+ }
92+}
93+
94+func TestHeadlessSurfaceHookErrorAborts(t *testing.T) {
95+ lines := runHeadless(t, "hook-error", "", 20*time.Second)
96+ joined := strings.Join(lines, "\n")
97+ if !strings.Contains(joined, "exit nefergui: surface hook: hook failed") || strings.Contains(joined, "view") {
98+ t.Fatalf("hook error must abort before any view:\n%s", joined)
99+ }
100+}
101+
102+func TestHeadlessSurfaceHookCancellation(t *testing.T) {
103+ lines := runHeadless(t, "hook-cancel", "", 20*time.Second)
104+ joined := strings.Join(lines, "\n")
105+ if !strings.Contains(joined, "exit context canceled") || strings.Contains(joined, "view") {
106+ t.Fatalf("cancellation during the hook must return ctx.Err before any view:\n%s", joined)
107+ }
108+}
109+
110+// TestHeadlessClientProcess is the client half; it does nothing under go test.
111+func TestHeadlessClientProcess(t *testing.T) {
112+ scenario := os.Getenv(headlessClientEnv)
113+ if scenario == "" {
114+ t.Skip("client process only")
115+ }
116+ out, err := os.Create(os.Getenv("NEFERGUI_UI_HEADLESS_OUT"))
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ var mu sync.Mutex
121+ logf := func(format string, args ...any) {
122+ mu.Lock()
123+ defer mu.Unlock()
124+ fmt.Fprintf(out, format+"\n", args...)
125+ }
126+ type model struct{ clicks int }
127+ m := &model{}
128+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
129+ defer cancel()
130+ view := func(f *Frame, m *model) {
131+ w, h := f.Size()
132+ logf("view size %vx%v", w, h)
133+ st := f.Root().Stack()
134+ st.Box(Key("box")).Rect(10, 20, 50, 40)
135+ if st.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
136+ m.clicks++
137+ logf("clicks %d", m.clicks)
138+ }
139+ }
140+ options := []WindowOption{Size(300, 200), Transparent()}
141+ switch scenario {
142+ case "layer":
143+ wake := make(chan struct{}, 1)
144+ // Nothing else redraws an idle window before input starts at 3s.
145+ go func() { time.Sleep(1500 * time.Millisecond); logf("wake-sent"); wake <- struct{}{} }()
146+ options = append(options,
147+ Layer(LayerConfig{Level: LayerOverlay, Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, Keyboard: KeyboardExclusive}),
148+ Wake(wake),
149+ OnSurface(func(context.Context, WaylandSurface) error { logf("surface"); return nil }),
150+ OnResize(func(w, h int, s float64) { logf("resize %d %d %v", w, h, s) }),
151+ OnInput(func(ev InputEvent) bool {
152+ switch {
153+ case ev.Kind == InputPointerPress:
154+ logf("input press")
155+ return true
156+ case ev.Kind == InputKey && ev.Pressed && ev.KeyName == "Escape":
157+ logf("input key Escape")
158+ cancel()
159+ }
160+ return false
161+ }),
162+ )
163+ case "hook-error":
164+ options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
165+ OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") }))
166+ case "hook-cancel":
167+ options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
168+ OnSurface(func(ctx context.Context, _ WaylandSurface) error {
169+ go func() { time.Sleep(200 * time.Millisecond); cancel() }()
170+ <-ctx.Done()
171+ return nil
172+ }))
173+ }
174+ err = Run(ctx, m, view, options...)
175+ logf("exit %v", err)
176+ out.Close()
177+ os.Exit(0)
178+}
A internal/ui/hooks.go +277 -0
@@ -0,0 +1,277 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "math"
8+
9+ "github.com/bnema/wlturbo/protocol/core"
10+ "github.com/bnema/wlturbo/wl"
11+
12+ "github.com/bnema/nefergui/internal/platform/wayland"
13+)
14+
15+// InputKind classifies an InputEvent.
16+type InputKind uint8
17+
18+const (
19+ InputPointerMotion InputKind = iota + 1
20+ InputPointerPress
21+ InputPointerRelease
22+ InputPointerAxis
23+ InputPointerLeave
24+ InputKey
25+ InputFocusIn
26+ InputFocusOut
27+ InputReset // platform dropped queued input; treat held state as released
28+)
29+
30+// Modifiers is a bit set of keyboard modifiers held during a key event.
31+type Modifiers uint8
32+
33+const (
34+ ModShift Modifiers = 1 << iota
35+ ModCtrl
36+)
37+
38+// InputEvent is a raw platform event, delivered on the owner loop before the
39+// normal control routing. Coordinates are logical, surface-local pixels.
40+// Size changes are not input; use OnResize and Frame.Size. Which fields are set
41+// depends on Kind:
42+//
43+// pointer motion/press/release/axis: X, Y; press/release: Button (evdev code)
44+// and Pressed; axis: DX, DY
45+// key: KeyName, Text, Modifiers, Pressed, Repeat
46+type InputEvent struct {
47+ Kind InputKind
48+ X, Y float64
49+ DX, DY float64
50+ Button uint32
51+ Pressed bool
52+ Repeat bool
53+ KeyName string
54+ Text string
55+ Modifiers Modifiers
56+}
57+
58+// OnInput delivers every raw input event to fn on the session owner loop,
59+// before the event reaches NeferGUI controls; controls still receive it. fn may
60+// mutate the model the view reads. It returns true when it changed state the
61+// view shows, which schedules a redraw; a false return costs no frame, so
62+// pointer motion does not force a build. A nil fn is ignored. fn must not block.
63+func OnInput(fn func(InputEvent) bool) WindowOption {
64+ return windowOption(func(w *windowConfig) {
65+ if fn != nil {
66+ w.onInput = fn
67+ }
68+ })
69+}
70+
71+// OnResize reports the logical size and scale once at start and again only
72+// when one of them changes; a change always schedules a redraw. It runs on the
73+// owner loop before the frame is built. A nil fn is ignored.
74+func OnResize(fn func(width, height int, scale float64)) WindowOption {
75+ return windowOption(func(w *windowConfig) {
76+ if fn != nil {
77+ w.onResize = fn
78+ }
79+ })
80+}
81+
82+// Wake requests a redraw whenever a value arrives on ch, so a view can react
83+// to state changed by other goroutines. The caller synchronizes such state;
84+// the view still runs only on the owner loop. Closing ch stops forwarding. A nil ch is ignored.
85+func Wake(ch <-chan struct{}) WindowOption {
86+ return windowOption(func(w *windowConfig) {
87+ if ch != nil {
88+ w.wake = ch
89+ }
90+ })
91+}
92+
93+// inputHooks holds the optional callbacks; all methods run on the owner loop.
94+type inputHooks struct {
95+ onInput func(InputEvent) bool
96+ onResize func(int, int, float64)
97+ haveSize bool
98+ width, height int
99+ scale float64
100+}
101+
102+func toInputEvent(in wayland.Input) (InputEvent, bool) {
103+ ev := InputEvent{X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button}
104+ switch in.Kind {
105+ case "motion":
106+ ev.Kind = InputPointerMotion
107+ case "press":
108+ ev.Kind, ev.Pressed = InputPointerPress, true
109+ case "release":
110+ ev.Kind = InputPointerRelease
111+ case "axis":
112+ ev.Kind = InputPointerAxis
113+ case "leave":
114+ ev.Kind = InputPointerLeave
115+ case "key":
116+ ev.Kind = InputKey
117+ ev.KeyName, ev.Text = in.Key.Name, in.Key.Text
118+ ev.Pressed, ev.Repeat = in.Key.Pressed, in.Key.Repeat
119+ if in.Key.Shift {
120+ ev.Modifiers |= ModShift
121+ }
122+ if in.Key.Ctrl {
123+ ev.Modifiers |= ModCtrl
124+ }
125+ case "focus-in":
126+ ev.Kind = InputFocusIn
127+ case "focus-out":
128+ ev.Kind = InputFocusOut
129+ case wayland.InputReset:
130+ ev.Kind = InputReset
131+ default:
132+ return InputEvent{}, false
133+ }
134+ return ev, true
135+}
136+
137+// input reports one platform input and whether the hook changed view state;
138+// it never allocates.
139+func (h *inputHooks) input(in wayland.Input) bool {
140+ if h.onInput == nil {
141+ return false
142+ }
143+ if ev, ok := toInputEvent(in); ok {
144+ return h.onInput(ev)
145+ }
146+ return false
147+}
148+
149+// resize reports a size or scale only when it differs from the last report and
150+// reports whether it did.
151+func (h *inputHooks) resize(width, height int, scale float64) bool {
152+ if h.haveSize && h.width == width && h.height == height && h.scale == scale {
153+ return false
154+ }
155+ h.haveSize, h.width, h.height, h.scale = true, width, height, scale
156+ if h.onResize != nil {
157+ h.onResize(width, height, scale)
158+ }
159+ return true
160+}
161+
162+// forwardWake turns external wake tokens into redraw requests. Redraw records
163+// the request before signalling the owner loop, so the next Build is not idle.
164+func (r *runtime) forwardWake(ctx context.Context, ch <-chan struct{}) {
165+ for {
166+ select {
167+ case <-ctx.Done():
168+ return
169+ case _, ok := <-ch:
170+ if !ok {
171+ return
172+ }
173+ r.Redraw()
174+ }
175+ }
176+}
177+
178+// WaylandSurface exposes the window's Wayland connection and wl_surface so an
179+// integrator can register extra protocol objects on the same client, such as
180+// clipboard or surface-marking extensions. It is the escape hatch to the
181+// underlying wlturbo objects; it does not add any protocol itself.
182+//
183+// The values are owned by NeferGUI and valid until Run returns. Do not close
184+// the Display, destroy the Surface or attach buffers. Events for objects the
185+// callback creates are dispatched on the owner goroutine while requests such as
186+// roundtrips run before the event reader starts, and on NeferGUI's reader
187+// goroutine afterwards, so handlers must synchronize any state they share.
188+type WaylandSurface struct {
189+ Display *wl.Display
190+ Surface *core.Surface
191+}
192+
193+// OnSurface calls fn once on the owner goroutine, after the surface has its
194+// role (xdg toplevel or layer surface) and its first configure, and before the
195+// first buffer is attached, so the window is not yet visible. ctx is the Run
196+// context: if it is cancelled while fn runs, NeferGUI closes the display so
197+// blocking protocol calls fail, and Run returns ctx.Err() (joined with any
198+// other error fn returned). A non-nil error aborts Run and closes the session.
199+// A nil fn is ignored.
200+func OnSurface(fn func(context.Context, WaylandSurface) error) WindowOption {
201+ return windowOption(func(w *windowConfig) {
202+ if fn != nil {
203+ w.onSurface = fn
204+ }
205+ })
206+}
207+
208+// SetInputRects stages the input region for the surface: pointer input outside
209+// the rectangles passes through. It is applied after the view returns and
210+// and committed together with the frame's buffer, with no extra commit. nil restores the whole surface, and a non-nil
211+// empty slice makes the surface click-through. Rects need positive size. The
212+// slice is copied, so callers may reuse it; an unchanged region costs nothing.
213+// If the view never calls it, the Layer config's InputRects stay in effect.
214+func (f *Frame) SetInputRects(rects []Rect) error {
215+ if !f.active {
216+ panic("nefergui: SetInputRects outside frame")
217+ }
218+ for i, r := range rects {
219+ if r.Width <= 0 || r.Height <= 0 || int64(r.X)+int64(r.Width) > math.MaxInt32 || int64(r.Y)+int64(r.Height) > math.MaxInt32 {
220+ return fmt.Errorf("nefergui: input rect %d %+v is empty or overflows", i, r)
221+ }
222+ }
223+ o := f.owner
224+ o.inputNil = rects == nil
225+ o.inputRects = append(o.inputRects[:0], rects...)
226+ o.inputStaged = true
227+ return nil
228+}
229+
230+// applyInputRects hands a staged region to the platform's stage function, which
231+// must not commit: the region rides the frame's own buffer commit. The region
232+// stays staged until stage succeeds, so a failure is retried on the next frame.
233+func (r *runtime) applyInputRects(stage func([]wayland.Rect) error) error {
234+ if !r.inputStaged {
235+ return nil
236+ }
237+ if r.inputNil {
238+ if err := stage(nil); err != nil {
239+ return err
240+ }
241+ r.inputStaged = false
242+ return nil
243+ }
244+ out := r.inputOut[:0]
245+ if out == nil {
246+ out = []wayland.Rect{}
247+ }
248+ for _, q := range r.inputRects {
249+ out = append(out, wayland.Rect{X: q.X, Y: q.Y, W: q.Width, H: q.Height})
250+ }
251+ r.inputOut = out
252+ if err := stage(out); err != nil {
253+ return err
254+ }
255+ r.inputStaged = false
256+ return nil
257+}
258+
259+// startupHook runs the OnSurface callback. When ctx can be cancelled, the
260+// display is closed on cancellation so blocking calls in fn return; a
261+// cancelled ctx is reported joined with fn's own error.
262+func startupHook(ctx context.Context, fn func(context.Context, WaylandSurface) error, s WaylandSurface) error {
263+ if ctx.Done() != nil {
264+ closed := make(chan struct{})
265+ stop := context.AfterFunc(ctx, func() { _ = s.Display.Close(); close(closed) })
266+ defer func() {
267+ if !stop() {
268+ <-closed // the close callback already started; let it finish
269+ }
270+ }()
271+ }
272+ err := fn(ctx, s)
273+ if cerr := ctx.Err(); cerr != nil {
274+ return errors.Join(cerr, err)
275+ }
276+ return err
277+}
@@ -0,0 +1,277 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "math"
8+
9+ "github.com/bnema/wlturbo/protocol/core"
10+ "github.com/bnema/wlturbo/wl"
11+
12+ "github.com/bnema/nefergui/internal/platform/wayland"
13+)
14+
15+// InputKind classifies an InputEvent.
16+type InputKind uint8
17+
18+const (
19+ InputPointerMotion InputKind = iota + 1
20+ InputPointerPress
21+ InputPointerRelease
22+ InputPointerAxis
23+ InputPointerLeave
24+ InputKey
25+ InputFocusIn
26+ InputFocusOut
27+ InputReset // platform dropped queued input; treat held state as released
28+)
29+
30+// Modifiers is a bit set of keyboard modifiers held during a key event.
31+type Modifiers uint8
32+
33+const (
34+ ModShift Modifiers = 1 << iota
35+ ModCtrl
36+)
37+
38+// InputEvent is a raw platform event, delivered on the owner loop before the
39+// normal control routing. Coordinates are logical, surface-local pixels.
40+// Size changes are not input; use OnResize and Frame.Size. Which fields are set
41+// depends on Kind:
42+//
43+// pointer motion/press/release/axis: X, Y; press/release: Button (evdev code)
44+// and Pressed; axis: DX, DY
45+// key: KeyName, Text, Modifiers, Pressed, Repeat
46+type InputEvent struct {
47+ Kind InputKind
48+ X, Y float64
49+ DX, DY float64
50+ Button uint32
51+ Pressed bool
52+ Repeat bool
53+ KeyName string
54+ Text string
55+ Modifiers Modifiers
56+}
57+
58+// OnInput delivers every raw input event to fn on the session owner loop,
59+// before the event reaches NeferGUI controls; controls still receive it. fn may
60+// mutate the model the view reads. It returns true when it changed state the
61+// view shows, which schedules a redraw; a false return costs no frame, so
62+// pointer motion does not force a build. A nil fn is ignored. fn must not block.
63+func OnInput(fn func(InputEvent) bool) WindowOption {
64+ return windowOption(func(w *windowConfig) {
65+ if fn != nil {
66+ w.onInput = fn
67+ }
68+ })
69+}
70+
71+// OnResize reports the logical size and scale once at start and again only
72+// when one of them changes; a change always schedules a redraw. It runs on the
73+// owner loop before the frame is built. A nil fn is ignored.
74+func OnResize(fn func(width, height int, scale float64)) WindowOption {
75+ return windowOption(func(w *windowConfig) {
76+ if fn != nil {
77+ w.onResize = fn
78+ }
79+ })
80+}
81+
82+// Wake requests a redraw whenever a value arrives on ch, so a view can react
83+// to state changed by other goroutines. The caller synchronizes such state;
84+// the view still runs only on the owner loop. Closing ch stops forwarding. A nil ch is ignored.
85+func Wake(ch <-chan struct{}) WindowOption {
86+ return windowOption(func(w *windowConfig) {
87+ if ch != nil {
88+ w.wake = ch
89+ }
90+ })
91+}
92+
93+// inputHooks holds the optional callbacks; all methods run on the owner loop.
94+type inputHooks struct {
95+ onInput func(InputEvent) bool
96+ onResize func(int, int, float64)
97+ haveSize bool
98+ width, height int
99+ scale float64
100+}
101+
102+func toInputEvent(in wayland.Input) (InputEvent, bool) {
103+ ev := InputEvent{X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button}
104+ switch in.Kind {
105+ case "motion":
106+ ev.Kind = InputPointerMotion
107+ case "press":
108+ ev.Kind, ev.Pressed = InputPointerPress, true
109+ case "release":
110+ ev.Kind = InputPointerRelease
111+ case "axis":
112+ ev.Kind = InputPointerAxis
113+ case "leave":
114+ ev.Kind = InputPointerLeave
115+ case "key":
116+ ev.Kind = InputKey
117+ ev.KeyName, ev.Text = in.Key.Name, in.Key.Text
118+ ev.Pressed, ev.Repeat = in.Key.Pressed, in.Key.Repeat
119+ if in.Key.Shift {
120+ ev.Modifiers |= ModShift
121+ }
122+ if in.Key.Ctrl {
123+ ev.Modifiers |= ModCtrl
124+ }
125+ case "focus-in":
126+ ev.Kind = InputFocusIn
127+ case "focus-out":
128+ ev.Kind = InputFocusOut
129+ case wayland.InputReset:
130+ ev.Kind = InputReset
131+ default:
132+ return InputEvent{}, false
133+ }
134+ return ev, true
135+}
136+
137+// input reports one platform input and whether the hook changed view state;
138+// it never allocates.
139+func (h *inputHooks) input(in wayland.Input) bool {
140+ if h.onInput == nil {
141+ return false
142+ }
143+ if ev, ok := toInputEvent(in); ok {
144+ return h.onInput(ev)
145+ }
146+ return false
147+}
148+
149+// resize reports a size or scale only when it differs from the last report and
150+// reports whether it did.
151+func (h *inputHooks) resize(width, height int, scale float64) bool {
152+ if h.haveSize && h.width == width && h.height == height && h.scale == scale {
153+ return false
154+ }
155+ h.haveSize, h.width, h.height, h.scale = true, width, height, scale
156+ if h.onResize != nil {
157+ h.onResize(width, height, scale)
158+ }
159+ return true
160+}
161+
162+// forwardWake turns external wake tokens into redraw requests. Redraw records
163+// the request before signalling the owner loop, so the next Build is not idle.
164+func (r *runtime) forwardWake(ctx context.Context, ch <-chan struct{}) {
165+ for {
166+ select {
167+ case <-ctx.Done():
168+ return
169+ case _, ok := <-ch:
170+ if !ok {
171+ return
172+ }
173+ r.Redraw()
174+ }
175+ }
176+}
177+
178+// WaylandSurface exposes the window's Wayland connection and wl_surface so an
179+// integrator can register extra protocol objects on the same client, such as
180+// clipboard or surface-marking extensions. It is the escape hatch to the
181+// underlying wlturbo objects; it does not add any protocol itself.
182+//
183+// The values are owned by NeferGUI and valid until Run returns. Do not close
184+// the Display, destroy the Surface or attach buffers. Events for objects the
185+// callback creates are dispatched on the owner goroutine while requests such as
186+// roundtrips run before the event reader starts, and on NeferGUI's reader
187+// goroutine afterwards, so handlers must synchronize any state they share.
188+type WaylandSurface struct {
189+ Display *wl.Display
190+ Surface *core.Surface
191+}
192+
193+// OnSurface calls fn once on the owner goroutine, after the surface has its
194+// role (xdg toplevel or layer surface) and its first configure, and before the
195+// first buffer is attached, so the window is not yet visible. ctx is the Run
196+// context: if it is cancelled while fn runs, NeferGUI closes the display so
197+// blocking protocol calls fail, and Run returns ctx.Err() (joined with any
198+// other error fn returned). A non-nil error aborts Run and closes the session.
199+// A nil fn is ignored.
200+func OnSurface(fn func(context.Context, WaylandSurface) error) WindowOption {
201+ return windowOption(func(w *windowConfig) {
202+ if fn != nil {
203+ w.onSurface = fn
204+ }
205+ })
206+}
207+
208+// SetInputRects stages the input region for the surface: pointer input outside
209+// the rectangles passes through. It is applied after the view returns and
210+// and committed together with the frame's buffer, with no extra commit. nil restores the whole surface, and a non-nil
211+// empty slice makes the surface click-through. Rects need positive size. The
212+// slice is copied, so callers may reuse it; an unchanged region costs nothing.
213+// If the view never calls it, the Layer config's InputRects stay in effect.
214+func (f *Frame) SetInputRects(rects []Rect) error {
215+ if !f.active {
216+ panic("nefergui: SetInputRects outside frame")
217+ }
218+ for i, r := range rects {
219+ if r.Width <= 0 || r.Height <= 0 || int64(r.X)+int64(r.Width) > math.MaxInt32 || int64(r.Y)+int64(r.Height) > math.MaxInt32 {
220+ return fmt.Errorf("nefergui: input rect %d %+v is empty or overflows", i, r)
221+ }
222+ }
223+ o := f.owner
224+ o.inputNil = rects == nil
225+ o.inputRects = append(o.inputRects[:0], rects...)
226+ o.inputStaged = true
227+ return nil
228+}
229+
230+// applyInputRects hands a staged region to the platform's stage function, which
231+// must not commit: the region rides the frame's own buffer commit. The region
232+// stays staged until stage succeeds, so a failure is retried on the next frame.
233+func (r *runtime) applyInputRects(stage func([]wayland.Rect) error) error {
234+ if !r.inputStaged {
235+ return nil
236+ }
237+ if r.inputNil {
238+ if err := stage(nil); err != nil {
239+ return err
240+ }
241+ r.inputStaged = false
242+ return nil
243+ }
244+ out := r.inputOut[:0]
245+ if out == nil {
246+ out = []wayland.Rect{}
247+ }
248+ for _, q := range r.inputRects {
249+ out = append(out, wayland.Rect{X: q.X, Y: q.Y, W: q.Width, H: q.Height})
250+ }
251+ r.inputOut = out
252+ if err := stage(out); err != nil {
253+ return err
254+ }
255+ r.inputStaged = false
256+ return nil
257+}
258+
259+// startupHook runs the OnSurface callback. When ctx can be cancelled, the
260+// display is closed on cancellation so blocking calls in fn return; a
261+// cancelled ctx is reported joined with fn's own error.
262+func startupHook(ctx context.Context, fn func(context.Context, WaylandSurface) error, s WaylandSurface) error {
263+ if ctx.Done() != nil {
264+ closed := make(chan struct{})
265+ stop := context.AfterFunc(ctx, func() { _ = s.Display.Close(); close(closed) })
266+ defer func() {
267+ if !stop() {
268+ <-closed // the close callback already started; let it finish
269+ }
270+ }()
271+ }
272+ err := fn(ctx, s)
273+ if cerr := ctx.Err(); cerr != nil {
274+ return errors.Join(cerr, err)
275+ }
276+ return err
277+}
A internal/ui/hooks_test.go +432 -0
@@ -0,0 +1,432 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "math"
7+ "net"
8+ "path/filepath"
9+ "strconv"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/wlturbo"
14+ "github.com/bnema/wlturbo/wl"
15+
16+ "github.com/bnema/nefergui/internal/css"
17+ "github.com/bnema/nefergui/internal/keyboard"
18+ "github.com/bnema/nefergui/internal/layout"
19+ "github.com/bnema/nefergui/internal/platform/wayland"
20+ "github.com/bnema/nefergui/internal/text"
21+)
22+
23+func TestLayerConfigSurfaceOptions(t *testing.T) {
24+ c := LayerConfig{Output: "DP-1", Namespace: "ns", Level: LayerOverlay, Anchors: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom,
25+ Keyboard: KeyboardExclusive, ExclusiveZone: -1, Margin: [4]int32{1, 2, 3, 4}, InputRects: []Rect{{1, 2, 3, 4}}}
26+ got, err := c.surfaceOptions()
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ l := got.Layer
31+ if l == nil || l.Output != "DP-1" || l.Namespace != "ns" || l.Layer != wayland.LayerOverlay || l.Anchor != 15 ||
32+ l.Keyboard != wayland.KeyboardExclusive || l.ExclusiveZone != -1 || l.Margin != [4]int32{1, 2, 3, 4} {
33+ t.Fatalf("layer options: %+v", l)
34+ }
35+ if len(got.InputRects) != 1 || got.InputRects[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
36+ t.Fatalf("input rects: %+v", got.InputRects)
37+ }
38+ for level, want := range map[LayerLevel]uint32{LayerBackground: 0, LayerBottom: 1, LayerTop: 2, LayerOverlay: 3} {
39+ o, err := LayerConfig{Level: level}.surfaceOptions()
40+ if err != nil || o.Layer.Layer != want {
41+ t.Fatalf("level %d: %+v %v", level, o.Layer, err)
42+ }
43+ }
44+ // nil InputRects keeps the full surface; empty non-nil means click-through.
45+ if o, _ := (LayerConfig{Level: LayerTop}).surfaceOptions(); o.InputRects != nil {
46+ t.Fatal("nil rects became non-nil")
47+ }
48+ if o, _ := (LayerConfig{Level: LayerTop, InputRects: []Rect{}}).surfaceOptions(); o.InputRects == nil {
49+ t.Fatal("empty rects became nil")
50+ }
51+}
52+
53+func TestLayerConfigRejectsInvalid(t *testing.T) {
54+ for name, c := range map[string]LayerConfig{
55+ "zero level": {},
56+ "bad level": {Level: 9},
57+ "bad anchor": {Level: LayerTop, Anchors: 1 << 5},
58+ "bad keyboard": {Level: LayerTop, Keyboard: 7},
59+ "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}},
60+ "nul output": {Level: LayerTop, Output: "a\x00"},
61+ } {
62+ if _, err := c.surfaceOptions(); err == nil {
63+ t.Errorf("%s: expected error", name)
64+ }
65+ }
66+}
67+
68+func TestLayerCopiesInputRects(t *testing.T) {
69+ rects := []Rect{{0, 0, 1, 1}}
70+ var cfg windowConfig
71+ Layer(LayerConfig{Level: LayerTop, InputRects: rects}).window(&cfg)
72+ rects[0].Width = 99
73+ if cfg.layer.InputRects[0].Width != 1 {
74+ t.Fatal("option aliases caller slice")
75+ }
76+}
77+
78+func TestInputEventMapping(t *testing.T) {
79+ ev, ok := toInputEvent(wayland.Input{Kind: "press", X: 1.5, Y: 2.5, Button: 0x110})
80+ if !ok || ev.Kind != InputPointerPress || !ev.Pressed || ev.X != 1.5 || ev.Y != 2.5 || ev.Button != 0x110 {
81+ t.Fatalf("press: %+v", ev)
82+ }
83+ ev, _ = toInputEvent(wayland.Input{Kind: "release", Button: 0x110})
84+ if ev.Kind != InputPointerRelease || ev.Pressed {
85+ t.Fatalf("release: %+v", ev)
86+ }
87+ ev, ok = toInputEvent(wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Text: "x", Pressed: true, Repeat: true, Shift: true, Ctrl: true}})
88+ if !ok || ev.Kind != InputKey || ev.KeyName != "Escape" || ev.Text != "x" || !ev.Pressed || !ev.Repeat || ev.Modifiers != ModShift|ModCtrl {
89+ t.Fatalf("key: %+v", ev)
90+ }
91+ ev, _ = toInputEvent(wayland.Input{Kind: "axis", DX: 1, DY: -2})
92+ if ev.Kind != InputPointerAxis || ev.DX != 1 || ev.DY != -2 {
93+ t.Fatalf("axis: %+v", ev)
94+ }
95+ for kind, want := range map[string]InputKind{"motion": InputPointerMotion, "leave": InputPointerLeave, "focus-in": InputFocusIn, "focus-out": InputFocusOut, wayland.InputReset: InputReset} {
96+ if ev, ok = toInputEvent(wayland.Input{Kind: kind}); !ok || ev.Kind != want {
97+ t.Fatalf("%s: %+v", kind, ev)
98+ }
99+ }
100+ if _, ok = toInputEvent(wayland.Input{Kind: "unknown"}); ok {
101+ t.Fatal("unknown kind delivered")
102+ }
103+}
104+
105+func TestInputHookDelivery(t *testing.T) {
106+ var got []InputEvent
107+ h := &inputHooks{onInput: func(ev InputEvent) bool { got = append(got, ev); return ev.Kind == InputPointerPress }}
108+ if h.input(wayland.Input{Kind: "motion", X: 3, Y: 4}) {
109+ t.Fatal("motion reported a change")
110+ }
111+ if !h.input(wayland.Input{Kind: "press", Button: 1}) {
112+ t.Fatal("press change not reported")
113+ }
114+ if len(got) != 2 || got[0].Kind != InputPointerMotion {
115+ t.Fatalf("got %+v", got)
116+ }
117+ // Absent callbacks and steady-state delivery do not allocate.
118+ idle := &inputHooks{}
119+ in := wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Pressed: true}}
120+ if n := testing.AllocsPerRun(100, func() { idle.input(in) }); n != 0 {
121+ t.Fatalf("idle input allocs %v", n)
122+ }
123+ count := 0
124+ live := &inputHooks{onInput: func(InputEvent) bool { count++; return false }}
125+ if n := testing.AllocsPerRun(100, func() { live.input(in) }); n != 0 {
126+ t.Fatalf("hooked input allocs %v", n)
127+ }
128+ if n := testing.AllocsPerRun(100, func() { idle.resize(10, 10, 1) }); n != 0 {
129+ t.Fatalf("idle resize allocs %v", n)
130+ }
131+}
132+
133+func TestResizeReportedOnlyOnChange(t *testing.T) {
134+ type size struct {
135+ w, h int
136+ s float64
137+ }
138+ var got []size
139+ h := &inputHooks{onResize: func(w, hh int, s float64) { got = append(got, size{w, hh, s}) }}
140+ var changed []bool
141+ for _, s := range []size{{100, 50, 1}, {100, 50, 1}, {100, 60, 1}, {100, 60, 1}, {100, 60, 2}, {100, 60, 2}} {
142+ changed = append(changed, h.resize(s.w, s.h, s.s))
143+ }
144+ want := []size{{100, 50, 1}, {100, 60, 1}, {100, 60, 2}}
145+ if len(got) != len(want) {
146+ t.Fatalf("resize reports %v", got)
147+ }
148+ for i := range want {
149+ if got[i] != want[i] {
150+ t.Fatalf("report %d: %v", i, got[i])
151+ }
152+ }
153+ if !changed[0] || changed[1] || !changed[2] || changed[3] || !changed[4] || changed[5] {
154+ t.Fatalf("changed flags %v", changed)
155+ }
156+}
157+
158+func TestExternalWakeForcesBuild(t *testing.T) {
159+ r := newRuntime()
160+ builds := 0
161+ view := func(f *Frame) { builds++; f.Root() }
162+ r.Build(view)
163+ if r.Build(view) {
164+ t.Fatal("idle runtime rebuilt")
165+ }
166+ ch := make(chan struct{})
167+ ctx, cancel := context.WithCancel(context.Background())
168+ defer cancel()
169+ done := make(chan struct{})
170+ go func() { r.forwardWake(ctx, ch); close(done) }()
171+ ch <- struct{}{}
172+ waitCtx, waitCancel := context.WithTimeout(ctx, time.Second)
173+ defer waitCancel()
174+ if err := r.Wait(waitCtx); err != nil {
175+ t.Fatal("wake not forwarded:", err)
176+ }
177+ if !r.Build(view) || builds != 2 {
178+ t.Fatalf("wake did not rebuild: builds=%d", builds)
179+ }
180+ close(ch)
181+ select {
182+ case <-done:
183+ case <-time.After(time.Second):
184+ t.Fatal("forwarder did not stop on close")
185+ }
186+}
187+
188+func TestNodeRectAndFrameSize(t *testing.T) {
189+ r := newRuntime()
190+ r.width, r.height = 400, 300
191+ var w, h float64
192+ r.Build(func(f *Frame) {
193+ w, h = f.Size()
194+ stack := f.Root().Stack()
195+ stack.Box(Key("a")).Rect(10, 20, 30, 40)
196+ stack.Box(Key("b")).Rect(50.5, 60.25, 70, 80)
197+ })
198+ if w != 400 || h != 300 {
199+ t.Fatalf("size %v x %v", w, h)
200+ }
201+ kids := r.output.Tree.Children[0].Children
202+ if len(kids) != 2 {
203+ t.Fatalf("children %d", len(kids))
204+ }
205+ if kids[0].Border != (layout.Rect{X: 10, Y: 20, W: 30, H: 40}) || kids[1].Border != (layout.Rect{X: 50.5, Y: 60.25, W: 70, H: 80}) {
206+ t.Fatalf("rects %+v %+v", kids[0].Border, kids[1].Border)
207+ }
208+ // Hit testing follows the rect geometry.
209+ if id := layout.Hit(r.output.Tree, 12, 22); id != kids[0].ID {
210+ t.Fatalf("hit %q want %q", id, kids[0].ID)
211+ }
212+}
213+
214+// Raw input observation must not replace normal control routing.
215+func TestRawHookDoesNotBlockControls(t *testing.T) {
216+ r := newRuntime()
217+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ r.setTextEngine(text.NewEngine(catalog))
222+ r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px} button {height:20px}`))
223+ activated := 0
224+ view := shell(r, &activated)
225+ r.Build(view)
226+ button := r.output.Tree.Children[1]
227+ seen := 0
228+ h := &inputHooks{onInput: func(ev InputEvent) bool {
229+ if ev.Kind == InputPointerPress || ev.Kind == InputPointerRelease {
230+ seen++
231+ }
232+ return false
233+ }}
234+ for _, in := range []wayland.Input{
235+ {Kind: "motion", X: 1, Y: button.Border.Y + 1},
236+ {Kind: "press", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
237+ {Kind: "release", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
238+ } {
239+ h.input(in)
240+ r.routeInput(in)
241+ }
242+ r.Build(view)
243+ if seen != 2 || activated != 1 {
244+ t.Fatalf("seen %d activated %d", seen, activated)
245+ }
246+}
247+
248+func TestSurfaceHookOptionIsStored(t *testing.T) {
249+ var cfg windowConfig
250+ OnSurface(nil).window(&cfg)
251+ if cfg.onSurface != nil {
252+ t.Fatal("nil hook stored")
253+ }
254+ called := false
255+ OnSurface(func(context.Context, WaylandSurface) error { called = true; return nil }).window(&cfg)
256+ if cfg.onSurface == nil || cfg.onSurface(context.Background(), WaylandSurface{}) != nil || !called {
257+ t.Fatal("hook not stored")
258+ }
259+}
260+
261+// testDisplay is a real wlturbo display over a unix socket with no compositor.
262+func testDisplay(t *testing.T) *wl.Display {
263+ t.Helper()
264+ l, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
265+ if err != nil {
266+ t.Fatal(err)
267+ }
268+ defer l.Close()
269+ accepted := make(chan net.Conn, 1)
270+ go func() {
271+ c, _ := l.Accept()
272+ accepted <- c
273+ }()
274+ c, err := net.Dial("unix", l.Addr().String())
275+ if err != nil {
276+ t.Fatal(err)
277+ }
278+ server := <-accepted
279+ t.Cleanup(func() { server.Close() })
280+ d, err := wlturbo.ConnectFromConn(c)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ t.Cleanup(func() { d.Close() })
285+ return d
286+}
287+
288+func TestStartupHookErrorsAndCancellation(t *testing.T) {
289+ boom := errors.New("boom")
290+ // A plain error passes through and does not need a display.
291+ if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return boom }, WaylandSurface{}); !errors.Is(err, boom) {
292+ t.Fatalf("error: %v", err)
293+ }
294+ if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return nil }, WaylandSurface{}); err != nil {
295+ t.Fatalf("nil: %v", err)
296+ }
297+ // A hook that observes cancellation reports ctx.Err joined with its own error.
298+ display := testDisplay(t)
299+ ctx, cancel := context.WithCancel(context.Background())
300+ err := startupHook(ctx, func(ctx context.Context, _ WaylandSurface) error {
301+ cancel()
302+ <-ctx.Done()
303+ return boom
304+ }, WaylandSurface{Display: display})
305+ if !errors.Is(err, context.Canceled) || !errors.Is(err, boom) || !display.Closed() {
306+ t.Fatalf("cancel: %v", err)
307+ }
308+ // Cancelling without a hook error still reports the cancellation.
309+ ctx, cancel = context.WithCancel(context.Background())
310+ err = startupHook(ctx, func(context.Context, WaylandSurface) error { cancel(); return nil }, WaylandSurface{Display: testDisplay(t)})
311+ if !errors.Is(err, context.Canceled) {
312+ t.Fatalf("cancel without error: %v", err)
313+ }
314+}
315+
316+func TestFrameSetInputRects(t *testing.T) {
317+ r := newRuntime()
318+ var setErr error
319+ rects := []Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}
320+ r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects(rects) })
321+ if setErr != nil {
322+ t.Fatal(setErr)
323+ }
324+ rects[0].Width = 99 // caller reuse must not affect the staged copy
325+ var got [][]wayland.Rect
326+ set := func(rs []wayland.Rect) error { got = append(got, append([]wayland.Rect{}, rs...)); return nil }
327+ if err := r.applyInputRects(set); err != nil {
328+ t.Fatal(err)
329+ }
330+ if len(got) != 1 || len(got[0]) != 2 || got[0][0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
331+ t.Fatalf("applied %+v", got)
332+ }
333+ // Nothing staged: the platform is not called again.
334+ if err := r.applyInputRects(set); err != nil || len(got) != 1 {
335+ t.Fatalf("restaged: %v %+v", err, got)
336+ }
337+ // nil restores the full surface; empty non-nil is click-through.
338+ var nilSeen, emptySeen bool
339+ r.Redraw()
340+ r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects(nil) })
341+ _ = r.applyInputRects(func(rs []wayland.Rect) error { nilSeen = rs == nil; return nil })
342+ r.Redraw()
343+ r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{}) })
344+ _ = r.applyInputRects(func(rs []wayland.Rect) error { emptySeen = rs != nil && len(rs) == 0; return nil })
345+ if !nilSeen || !emptySeen {
346+ t.Fatalf("nil %v empty %v", nilSeen, emptySeen)
347+ }
348+ // Invalid rects are rejected in the frame call and stage nothing.
349+ r.Redraw()
350+ for _, bad := range []Rect{{0, 0, 0, 5}, {0, 0, 5, -1}, {math.MaxInt32, 0, 5, 5}} {
351+ r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects([]Rect{bad}) })
352+ if setErr == nil || r.inputStaged {
353+ t.Fatalf("accepted %+v", bad)
354+ }
355+ r.Redraw()
356+ }
357+ // Steady-state staging and apply do not allocate once buffers exist.
358+ r.Build(func(f *Frame) {
359+ f.Root()
360+ if n := testing.AllocsPerRun(50, func() { _ = f.SetInputRects(rects) }); n != 0 {
361+ t.Fatalf("SetInputRects allocs %v", n)
362+ }
363+ })
364+ noop := func([]wayland.Rect) error { return nil }
365+ r.inputStaged = true
366+ if n := testing.AllocsPerRun(50, func() { r.inputStaged = true; _ = r.applyInputRects(noop) }); n != 0 {
367+ t.Fatalf("applyInputRects allocs %v", n)
368+ }
369+}
370+
371+// A failed stage keeps the region pending so the next frame retries it.
372+func TestStagedInputRectsRetainedOnFailure(t *testing.T) {
373+ r := newRuntime()
374+ r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{{1, 2, 3, 4}}) })
375+ boom := errors.New("stage failed")
376+ if err := r.applyInputRects(func([]wayland.Rect) error { return boom }); !errors.Is(err, boom) || !r.inputStaged {
377+ t.Fatalf("failure: %v staged=%v", err, r.inputStaged)
378+ }
379+ var got []wayland.Rect
380+ if err := r.applyInputRects(func(rs []wayland.Rect) error { got = append(got, rs...); return nil }); err != nil || r.inputStaged {
381+ t.Fatalf("retry: %v staged=%v", err, r.inputStaged)
382+ }
383+ if len(got) != 1 || got[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
384+ t.Fatalf("retried region %+v", got)
385+ }
386+ // A later frame that does not call SetInputRects stages nothing new.
387+ r.Redraw()
388+ r.Build(func(f *Frame) { f.Root() })
389+ called := false
390+ _ = r.applyInputRects(func([]wayland.Rect) error { called = true; return nil })
391+ if called {
392+ t.Fatal("unchanged frame restaged the region")
393+ }
394+}
395+
396+func TestNodeRectOutsideStackIsIgnored(t *testing.T) {
397+ r := newRuntime()
398+ r.width, r.height = 200, 100
399+ r.Build(func(f *Frame) {
400+ col := f.Root().Column()
401+ col.Box(Key("a"), Inline("height:10px")).Rect(50, 50, 5, 5)
402+ col.Box(Key("b"), Inline("height:10px"))
403+ })
404+ kids := r.output.Tree.Children[0].Children
405+ if kids[0].Border.X != 0 || kids[0].Border.H != 10 || kids[1].Border.Y != 10 {
406+ t.Fatalf("Rect changed normal flow: %+v %+v", kids[0].Border, kids[1].Border)
407+ }
408+}
409+
410+// The absolute geometry path adds no allocations over the same tree without it.
411+func TestNodeRectAddsNoAllocations(t *testing.T) {
412+ build := func(withRect bool) func() {
413+ r := newRuntime()
414+ r.width, r.height = 640, 480
415+ view := func(f *Frame) {
416+ st := f.Root().Stack()
417+ for i := 0; i < 32; i++ {
418+ b := st.Box(Key(strconv.Itoa(i)), Inline("background:#fff"))
419+ if withRect {
420+ b.Rect(float64(i), float64(i), 20, 20)
421+ }
422+ }
423+ }
424+ r.Build(view)
425+ return func() { r.Redraw(); r.Build(view) }
426+ }
427+ plain := testing.AllocsPerRun(20, build(false))
428+ rect := testing.AllocsPerRun(20, build(true))
429+ if rect > plain {
430+ t.Fatalf("Rect added allocations: %v vs %v", rect, plain)
431+ }
432+}
@@ -0,0 +1,432 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "math"
7+ "net"
8+ "path/filepath"
9+ "strconv"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/wlturbo"
14+ "github.com/bnema/wlturbo/wl"
15+
16+ "github.com/bnema/nefergui/internal/css"
17+ "github.com/bnema/nefergui/internal/keyboard"
18+ "github.com/bnema/nefergui/internal/layout"
19+ "github.com/bnema/nefergui/internal/platform/wayland"
20+ "github.com/bnema/nefergui/internal/text"
21+)
22+
23+func TestLayerConfigSurfaceOptions(t *testing.T) {
24+ c := LayerConfig{Output: "DP-1", Namespace: "ns", Level: LayerOverlay, Anchors: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom,
25+ Keyboard: KeyboardExclusive, ExclusiveZone: -1, Margin: [4]int32{1, 2, 3, 4}, InputRects: []Rect{{1, 2, 3, 4}}}
26+ got, err := c.surfaceOptions()
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ l := got.Layer
31+ if l == nil || l.Output != "DP-1" || l.Namespace != "ns" || l.Layer != wayland.LayerOverlay || l.Anchor != 15 ||
32+ l.Keyboard != wayland.KeyboardExclusive || l.ExclusiveZone != -1 || l.Margin != [4]int32{1, 2, 3, 4} {
33+ t.Fatalf("layer options: %+v", l)
34+ }
35+ if len(got.InputRects) != 1 || got.InputRects[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
36+ t.Fatalf("input rects: %+v", got.InputRects)
37+ }
38+ for level, want := range map[LayerLevel]uint32{LayerBackground: 0, LayerBottom: 1, LayerTop: 2, LayerOverlay: 3} {
39+ o, err := LayerConfig{Level: level}.surfaceOptions()
40+ if err != nil || o.Layer.Layer != want {
41+ t.Fatalf("level %d: %+v %v", level, o.Layer, err)
42+ }
43+ }
44+ // nil InputRects keeps the full surface; empty non-nil means click-through.
45+ if o, _ := (LayerConfig{Level: LayerTop}).surfaceOptions(); o.InputRects != nil {
46+ t.Fatal("nil rects became non-nil")
47+ }
48+ if o, _ := (LayerConfig{Level: LayerTop, InputRects: []Rect{}}).surfaceOptions(); o.InputRects == nil {
49+ t.Fatal("empty rects became nil")
50+ }
51+}
52+
53+func TestLayerConfigRejectsInvalid(t *testing.T) {
54+ for name, c := range map[string]LayerConfig{
55+ "zero level": {},
56+ "bad level": {Level: 9},
57+ "bad anchor": {Level: LayerTop, Anchors: 1 << 5},
58+ "bad keyboard": {Level: LayerTop, Keyboard: 7},
59+ "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}},
60+ "nul output": {Level: LayerTop, Output: "a\x00"},
61+ } {
62+ if _, err := c.surfaceOptions(); err == nil {
63+ t.Errorf("%s: expected error", name)
64+ }
65+ }
66+}
67+
68+func TestLayerCopiesInputRects(t *testing.T) {
69+ rects := []Rect{{0, 0, 1, 1}}
70+ var cfg windowConfig
71+ Layer(LayerConfig{Level: LayerTop, InputRects: rects}).window(&cfg)
72+ rects[0].Width = 99
73+ if cfg.layer.InputRects[0].Width != 1 {
74+ t.Fatal("option aliases caller slice")
75+ }
76+}
77+
78+func TestInputEventMapping(t *testing.T) {
79+ ev, ok := toInputEvent(wayland.Input{Kind: "press", X: 1.5, Y: 2.5, Button: 0x110})
80+ if !ok || ev.Kind != InputPointerPress || !ev.Pressed || ev.X != 1.5 || ev.Y != 2.5 || ev.Button != 0x110 {
81+ t.Fatalf("press: %+v", ev)
82+ }
83+ ev, _ = toInputEvent(wayland.Input{Kind: "release", Button: 0x110})
84+ if ev.Kind != InputPointerRelease || ev.Pressed {
85+ t.Fatalf("release: %+v", ev)
86+ }
87+ ev, ok = toInputEvent(wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Text: "x", Pressed: true, Repeat: true, Shift: true, Ctrl: true}})
88+ if !ok || ev.Kind != InputKey || ev.KeyName != "Escape" || ev.Text != "x" || !ev.Pressed || !ev.Repeat || ev.Modifiers != ModShift|ModCtrl {
89+ t.Fatalf("key: %+v", ev)
90+ }
91+ ev, _ = toInputEvent(wayland.Input{Kind: "axis", DX: 1, DY: -2})
92+ if ev.Kind != InputPointerAxis || ev.DX != 1 || ev.DY != -2 {
93+ t.Fatalf("axis: %+v", ev)
94+ }
95+ for kind, want := range map[string]InputKind{"motion": InputPointerMotion, "leave": InputPointerLeave, "focus-in": InputFocusIn, "focus-out": InputFocusOut, wayland.InputReset: InputReset} {
96+ if ev, ok = toInputEvent(wayland.Input{Kind: kind}); !ok || ev.Kind != want {
97+ t.Fatalf("%s: %+v", kind, ev)
98+ }
99+ }
100+ if _, ok = toInputEvent(wayland.Input{Kind: "unknown"}); ok {
101+ t.Fatal("unknown kind delivered")
102+ }
103+}
104+
105+func TestInputHookDelivery(t *testing.T) {
106+ var got []InputEvent
107+ h := &inputHooks{onInput: func(ev InputEvent) bool { got = append(got, ev); return ev.Kind == InputPointerPress }}
108+ if h.input(wayland.Input{Kind: "motion", X: 3, Y: 4}) {
109+ t.Fatal("motion reported a change")
110+ }
111+ if !h.input(wayland.Input{Kind: "press", Button: 1}) {
112+ t.Fatal("press change not reported")
113+ }
114+ if len(got) != 2 || got[0].Kind != InputPointerMotion {
115+ t.Fatalf("got %+v", got)
116+ }
117+ // Absent callbacks and steady-state delivery do not allocate.
118+ idle := &inputHooks{}
119+ in := wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Pressed: true}}
120+ if n := testing.AllocsPerRun(100, func() { idle.input(in) }); n != 0 {
121+ t.Fatalf("idle input allocs %v", n)
122+ }
123+ count := 0
124+ live := &inputHooks{onInput: func(InputEvent) bool { count++; return false }}
125+ if n := testing.AllocsPerRun(100, func() { live.input(in) }); n != 0 {
126+ t.Fatalf("hooked input allocs %v", n)
127+ }
128+ if n := testing.AllocsPerRun(100, func() { idle.resize(10, 10, 1) }); n != 0 {
129+ t.Fatalf("idle resize allocs %v", n)
130+ }
131+}
132+
133+func TestResizeReportedOnlyOnChange(t *testing.T) {
134+ type size struct {
135+ w, h int
136+ s float64
137+ }
138+ var got []size
139+ h := &inputHooks{onResize: func(w, hh int, s float64) { got = append(got, size{w, hh, s}) }}
140+ var changed []bool
141+ for _, s := range []size{{100, 50, 1}, {100, 50, 1}, {100, 60, 1}, {100, 60, 1}, {100, 60, 2}, {100, 60, 2}} {
142+ changed = append(changed, h.resize(s.w, s.h, s.s))
143+ }
144+ want := []size{{100, 50, 1}, {100, 60, 1}, {100, 60, 2}}
145+ if len(got) != len(want) {
146+ t.Fatalf("resize reports %v", got)
147+ }
148+ for i := range want {
149+ if got[i] != want[i] {
150+ t.Fatalf("report %d: %v", i, got[i])
151+ }
152+ }
153+ if !changed[0] || changed[1] || !changed[2] || changed[3] || !changed[4] || changed[5] {
154+ t.Fatalf("changed flags %v", changed)
155+ }
156+}
157+
158+func TestExternalWakeForcesBuild(t *testing.T) {
159+ r := newRuntime()
160+ builds := 0
161+ view := func(f *Frame) { builds++; f.Root() }
162+ r.Build(view)
163+ if r.Build(view) {
164+ t.Fatal("idle runtime rebuilt")
165+ }
166+ ch := make(chan struct{})
167+ ctx, cancel := context.WithCancel(context.Background())
168+ defer cancel()
169+ done := make(chan struct{})
170+ go func() { r.forwardWake(ctx, ch); close(done) }()
171+ ch <- struct{}{}
172+ waitCtx, waitCancel := context.WithTimeout(ctx, time.Second)
173+ defer waitCancel()
174+ if err := r.Wait(waitCtx); err != nil {
175+ t.Fatal("wake not forwarded:", err)
176+ }
177+ if !r.Build(view) || builds != 2 {
178+ t.Fatalf("wake did not rebuild: builds=%d", builds)
179+ }
180+ close(ch)
181+ select {
182+ case <-done:
183+ case <-time.After(time.Second):
184+ t.Fatal("forwarder did not stop on close")
185+ }
186+}
187+
188+func TestNodeRectAndFrameSize(t *testing.T) {
189+ r := newRuntime()
190+ r.width, r.height = 400, 300
191+ var w, h float64
192+ r.Build(func(f *Frame) {
193+ w, h = f.Size()
194+ stack := f.Root().Stack()
195+ stack.Box(Key("a")).Rect(10, 20, 30, 40)
196+ stack.Box(Key("b")).Rect(50.5, 60.25, 70, 80)
197+ })
198+ if w != 400 || h != 300 {
199+ t.Fatalf("size %v x %v", w, h)
200+ }
201+ kids := r.output.Tree.Children[0].Children
202+ if len(kids) != 2 {
203+ t.Fatalf("children %d", len(kids))
204+ }
205+ if kids[0].Border != (layout.Rect{X: 10, Y: 20, W: 30, H: 40}) || kids[1].Border != (layout.Rect{X: 50.5, Y: 60.25, W: 70, H: 80}) {
206+ t.Fatalf("rects %+v %+v", kids[0].Border, kids[1].Border)
207+ }
208+ // Hit testing follows the rect geometry.
209+ if id := layout.Hit(r.output.Tree, 12, 22); id != kids[0].ID {
210+ t.Fatalf("hit %q want %q", id, kids[0].ID)
211+ }
212+}
213+
214+// Raw input observation must not replace normal control routing.
215+func TestRawHookDoesNotBlockControls(t *testing.T) {
216+ r := newRuntime()
217+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ r.setTextEngine(text.NewEngine(catalog))
222+ r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px} button {height:20px}`))
223+ activated := 0
224+ view := shell(r, &activated)
225+ r.Build(view)
226+ button := r.output.Tree.Children[1]
227+ seen := 0
228+ h := &inputHooks{onInput: func(ev InputEvent) bool {
229+ if ev.Kind == InputPointerPress || ev.Kind == InputPointerRelease {
230+ seen++
231+ }
232+ return false
233+ }}
234+ for _, in := range []wayland.Input{
235+ {Kind: "motion", X: 1, Y: button.Border.Y + 1},
236+ {Kind: "press", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
237+ {Kind: "release", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
238+ } {
239+ h.input(in)
240+ r.routeInput(in)
241+ }
242+ r.Build(view)
243+ if seen != 2 || activated != 1 {
244+ t.Fatalf("seen %d activated %d", seen, activated)
245+ }
246+}
247+
248+func TestSurfaceHookOptionIsStored(t *testing.T) {
249+ var cfg windowConfig
250+ OnSurface(nil).window(&cfg)
251+ if cfg.onSurface != nil {
252+ t.Fatal("nil hook stored")
253+ }
254+ called := false
255+ OnSurface(func(context.Context, WaylandSurface) error { called = true; return nil }).window(&cfg)
256+ if cfg.onSurface == nil || cfg.onSurface(context.Background(), WaylandSurface{}) != nil || !called {
257+ t.Fatal("hook not stored")
258+ }
259+}
260+
261+// testDisplay is a real wlturbo display over a unix socket with no compositor.
262+func testDisplay(t *testing.T) *wl.Display {
263+ t.Helper()
264+ l, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
265+ if err != nil {
266+ t.Fatal(err)
267+ }
268+ defer l.Close()
269+ accepted := make(chan net.Conn, 1)
270+ go func() {
271+ c, _ := l.Accept()
272+ accepted <- c
273+ }()
274+ c, err := net.Dial("unix", l.Addr().String())
275+ if err != nil {
276+ t.Fatal(err)
277+ }
278+ server := <-accepted
279+ t.Cleanup(func() { server.Close() })
280+ d, err := wlturbo.ConnectFromConn(c)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ t.Cleanup(func() { d.Close() })
285+ return d
286+}
287+
288+func TestStartupHookErrorsAndCancellation(t *testing.T) {
289+ boom := errors.New("boom")
290+ // A plain error passes through and does not need a display.
291+ if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return boom }, WaylandSurface{}); !errors.Is(err, boom) {
292+ t.Fatalf("error: %v", err)
293+ }
294+ if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return nil }, WaylandSurface{}); err != nil {
295+ t.Fatalf("nil: %v", err)
296+ }
297+ // A hook that observes cancellation reports ctx.Err joined with its own error.
298+ display := testDisplay(t)
299+ ctx, cancel := context.WithCancel(context.Background())
300+ err := startupHook(ctx, func(ctx context.Context, _ WaylandSurface) error {
301+ cancel()
302+ <-ctx.Done()
303+ return boom
304+ }, WaylandSurface{Display: display})
305+ if !errors.Is(err, context.Canceled) || !errors.Is(err, boom) || !display.Closed() {
306+ t.Fatalf("cancel: %v", err)
307+ }
308+ // Cancelling without a hook error still reports the cancellation.
309+ ctx, cancel = context.WithCancel(context.Background())
310+ err = startupHook(ctx, func(context.Context, WaylandSurface) error { cancel(); return nil }, WaylandSurface{Display: testDisplay(t)})
311+ if !errors.Is(err, context.Canceled) {
312+ t.Fatalf("cancel without error: %v", err)
313+ }
314+}
315+
316+func TestFrameSetInputRects(t *testing.T) {
317+ r := newRuntime()
318+ var setErr error
319+ rects := []Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}
320+ r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects(rects) })
321+ if setErr != nil {
322+ t.Fatal(setErr)
323+ }
324+ rects[0].Width = 99 // caller reuse must not affect the staged copy
325+ var got [][]wayland.Rect
326+ set := func(rs []wayland.Rect) error { got = append(got, append([]wayland.Rect{}, rs...)); return nil }
327+ if err := r.applyInputRects(set); err != nil {
328+ t.Fatal(err)
329+ }
330+ if len(got) != 1 || len(got[0]) != 2 || got[0][0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
331+ t.Fatalf("applied %+v", got)
332+ }
333+ // Nothing staged: the platform is not called again.
334+ if err := r.applyInputRects(set); err != nil || len(got) != 1 {
335+ t.Fatalf("restaged: %v %+v", err, got)
336+ }
337+ // nil restores the full surface; empty non-nil is click-through.
338+ var nilSeen, emptySeen bool
339+ r.Redraw()
340+ r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects(nil) })
341+ _ = r.applyInputRects(func(rs []wayland.Rect) error { nilSeen = rs == nil; return nil })
342+ r.Redraw()
343+ r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{}) })
344+ _ = r.applyInputRects(func(rs []wayland.Rect) error { emptySeen = rs != nil && len(rs) == 0; return nil })
345+ if !nilSeen || !emptySeen {
346+ t.Fatalf("nil %v empty %v", nilSeen, emptySeen)
347+ }
348+ // Invalid rects are rejected in the frame call and stage nothing.
349+ r.Redraw()
350+ for _, bad := range []Rect{{0, 0, 0, 5}, {0, 0, 5, -1}, {math.MaxInt32, 0, 5, 5}} {
351+ r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects([]Rect{bad}) })
352+ if setErr == nil || r.inputStaged {
353+ t.Fatalf("accepted %+v", bad)
354+ }
355+ r.Redraw()
356+ }
357+ // Steady-state staging and apply do not allocate once buffers exist.
358+ r.Build(func(f *Frame) {
359+ f.Root()
360+ if n := testing.AllocsPerRun(50, func() { _ = f.SetInputRects(rects) }); n != 0 {
361+ t.Fatalf("SetInputRects allocs %v", n)
362+ }
363+ })
364+ noop := func([]wayland.Rect) error { return nil }
365+ r.inputStaged = true
366+ if n := testing.AllocsPerRun(50, func() { r.inputStaged = true; _ = r.applyInputRects(noop) }); n != 0 {
367+ t.Fatalf("applyInputRects allocs %v", n)
368+ }
369+}
370+
371+// A failed stage keeps the region pending so the next frame retries it.
372+func TestStagedInputRectsRetainedOnFailure(t *testing.T) {
373+ r := newRuntime()
374+ r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{{1, 2, 3, 4}}) })
375+ boom := errors.New("stage failed")
376+ if err := r.applyInputRects(func([]wayland.Rect) error { return boom }); !errors.Is(err, boom) || !r.inputStaged {
377+ t.Fatalf("failure: %v staged=%v", err, r.inputStaged)
378+ }
379+ var got []wayland.Rect
380+ if err := r.applyInputRects(func(rs []wayland.Rect) error { got = append(got, rs...); return nil }); err != nil || r.inputStaged {
381+ t.Fatalf("retry: %v staged=%v", err, r.inputStaged)
382+ }
383+ if len(got) != 1 || got[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
384+ t.Fatalf("retried region %+v", got)
385+ }
386+ // A later frame that does not call SetInputRects stages nothing new.
387+ r.Redraw()
388+ r.Build(func(f *Frame) { f.Root() })
389+ called := false
390+ _ = r.applyInputRects(func([]wayland.Rect) error { called = true; return nil })
391+ if called {
392+ t.Fatal("unchanged frame restaged the region")
393+ }
394+}
395+
396+func TestNodeRectOutsideStackIsIgnored(t *testing.T) {
397+ r := newRuntime()
398+ r.width, r.height = 200, 100
399+ r.Build(func(f *Frame) {
400+ col := f.Root().Column()
401+ col.Box(Key("a"), Inline("height:10px")).Rect(50, 50, 5, 5)
402+ col.Box(Key("b"), Inline("height:10px"))
403+ })
404+ kids := r.output.Tree.Children[0].Children
405+ if kids[0].Border.X != 0 || kids[0].Border.H != 10 || kids[1].Border.Y != 10 {
406+ t.Fatalf("Rect changed normal flow: %+v %+v", kids[0].Border, kids[1].Border)
407+ }
408+}
409+
410+// The absolute geometry path adds no allocations over the same tree without it.
411+func TestNodeRectAddsNoAllocations(t *testing.T) {
412+ build := func(withRect bool) func() {
413+ r := newRuntime()
414+ r.width, r.height = 640, 480
415+ view := func(f *Frame) {
416+ st := f.Root().Stack()
417+ for i := 0; i < 32; i++ {
418+ b := st.Box(Key(strconv.Itoa(i)), Inline("background:#fff"))
419+ if withRect {
420+ b.Rect(float64(i), float64(i), 20, 20)
421+ }
422+ }
423+ }
424+ r.Build(view)
425+ return func() { r.Redraw(); r.Build(view) }
426+ }
427+ plain := testing.AllocsPerRun(20, build(false))
428+ rect := testing.AllocsPerRun(20, build(true))
429+ if rect > plain {
430+ t.Fatalf("Rect added allocations: %v vs %v", rect, plain)
431+ }
432+}
M internal/ui/interaction.go +1 -0
@@ -107,6 +107,7 @@ func (r *runtime) layoutTree(e *element) *layout.Node {
107107 if e.computed != nil {
108108 n.Style = e.computed.Style
109109 }
110+ n.Rect, n.HasRect = e.rect, e.hasRect
110111 if offset, ok := r.state.scroll[n.ID]; ok {
111112 n.ScrollX, n.ScrollY = offset.W, offset.H
112113 }
@@ -107,6 +107,7 @@ func (r *runtime) layoutTree(e *element) *layout.Node {
107 if e.computed != nil {107 if e.computed != nil {
108 n.Style = e.computed.Style108 n.Style = e.computed.Style
109 }109 }
110+ n.Rect, n.HasRect = e.rect, e.hasRect
110 if offset, ok := r.state.scroll[n.ID]; ok {111 if offset, ok := r.state.scroll[n.ID]; ok {
111 n.ScrollX, n.ScrollY = offset.W, offset.H112 n.ScrollX, n.ScrollY = offset.W, offset.H
112 }113 }
A internal/ui/layer.go +121 -0
@@ -0,0 +1,121 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "slices"
6+
7+ "github.com/bnema/nefergui/internal/platform/wayland"
8+)
9+
10+// LayerLevel is the wlr-layer-shell stacking layer. The zero value is invalid,
11+// so a surface never silently lands below every window.
12+type LayerLevel uint8
13+
14+const (
15+ LayerBackground LayerLevel = iota + 1
16+ LayerBottom
17+ LayerTop
18+ LayerOverlay
19+)
20+
21+// Anchor is a bit set of output edges a layer surface attaches to.
22+type Anchor uint8
23+
24+const (
25+ AnchorTop Anchor = 1 << iota
26+ AnchorBottom
27+ AnchorLeft
28+ AnchorRight
29+)
30+
31+const anchorAll = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
32+
33+// KeyboardMode is the layer surface keyboard interactivity. The zero value is
34+// KeyboardNone: the surface never takes keyboard focus. KeyboardOnDemand needs
35+// layer-shell version 4.
36+type KeyboardMode uint8
37+
38+const (
39+ KeyboardNone KeyboardMode = iota
40+ KeyboardExclusive
41+ KeyboardOnDemand
42+)
43+
44+// Rect is an integer logical-pixel rectangle in surface coordinates.
45+type Rect struct{ X, Y, Width, Height int32 }
46+
47+// LayerConfig requests a wlr-layer-shell surface instead of an xdg toplevel.
48+// Size still gives the initial logical size; an anchored axis is sized by the
49+// compositor and reported through OnResize and Frame.Size.
50+type LayerConfig struct {
51+ Output string // output name; empty lets the compositor choose
52+ Namespace string // defaults to "nefergui"
53+ Level LayerLevel
54+ Anchors Anchor
55+ Keyboard KeyboardMode
56+ ExclusiveZone int32
57+ Margin [4]int32 // top, right, bottom, left
58+ // InputRects, when non-nil, restricts pointer input to these rectangles.
59+ // An empty non-nil slice makes the surface fully click-through. Rects need
60+ // positive width and height.
61+ InputRects []Rect
62+}
63+
64+// surfaceOptions lowers the config to platform options and validates it.
65+func (c LayerConfig) surfaceOptions() (wayland.SurfaceOptions, error) {
66+ l := wayland.LayerOptions{Output: c.Output, Namespace: c.Namespace, ExclusiveZone: c.ExclusiveZone, Margin: c.Margin}
67+ switch c.Level {
68+ case LayerBackground:
69+ l.Layer = wayland.LayerBackground
70+ case LayerBottom:
71+ l.Layer = wayland.LayerBottom
72+ case LayerTop:
73+ l.Layer = wayland.LayerTop
74+ case LayerOverlay:
75+ l.Layer = wayland.LayerOverlay
76+ default:
77+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid layer level %d", c.Level)
78+ }
79+ if c.Anchors&^anchorAll != 0 {
80+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid layer anchors %#x", uint8(c.Anchors))
81+ }
82+ if c.Anchors&AnchorTop != 0 {
83+ l.Anchor |= wayland.AnchorTop
84+ }
85+ if c.Anchors&AnchorBottom != 0 {
86+ l.Anchor |= wayland.AnchorBottom
87+ }
88+ if c.Anchors&AnchorLeft != 0 {
89+ l.Anchor |= wayland.AnchorLeft
90+ }
91+ if c.Anchors&AnchorRight != 0 {
92+ l.Anchor |= wayland.AnchorRight
93+ }
94+ switch c.Keyboard {
95+ case KeyboardNone:
96+ l.Keyboard = wayland.KeyboardNone
97+ case KeyboardExclusive:
98+ l.Keyboard = wayland.KeyboardExclusive
99+ case KeyboardOnDemand:
100+ l.Keyboard = wayland.KeyboardOnDemand
101+ default:
102+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid keyboard mode %d", c.Keyboard)
103+ }
104+ o := wayland.SurfaceOptions{Layer: &l}
105+ if c.InputRects != nil {
106+ o.InputRects = make([]wayland.Rect, len(c.InputRects))
107+ for i, r := range c.InputRects {
108+ o.InputRects[i] = wayland.Rect{X: r.X, Y: r.Y, W: r.Width, H: r.Height}
109+ }
110+ }
111+ if err := o.Validate(); err != nil {
112+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: layer: %w", err)
113+ }
114+ return o, nil
115+}
116+
117+// Layer selects a wlr-layer-shell surface for the window.
118+func Layer(c LayerConfig) WindowOption {
119+ c.InputRects = slices.Clone(c.InputRects)
120+ return windowOption(func(w *windowConfig) { w.layer = &c })
121+}
@@ -0,0 +1,121 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "slices"
6+
7+ "github.com/bnema/nefergui/internal/platform/wayland"
8+)
9+
10+// LayerLevel is the wlr-layer-shell stacking layer. The zero value is invalid,
11+// so a surface never silently lands below every window.
12+type LayerLevel uint8
13+
14+const (
15+ LayerBackground LayerLevel = iota + 1
16+ LayerBottom
17+ LayerTop
18+ LayerOverlay
19+)
20+
21+// Anchor is a bit set of output edges a layer surface attaches to.
22+type Anchor uint8
23+
24+const (
25+ AnchorTop Anchor = 1 << iota
26+ AnchorBottom
27+ AnchorLeft
28+ AnchorRight
29+)
30+
31+const anchorAll = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
32+
33+// KeyboardMode is the layer surface keyboard interactivity. The zero value is
34+// KeyboardNone: the surface never takes keyboard focus. KeyboardOnDemand needs
35+// layer-shell version 4.
36+type KeyboardMode uint8
37+
38+const (
39+ KeyboardNone KeyboardMode = iota
40+ KeyboardExclusive
41+ KeyboardOnDemand
42+)
43+
44+// Rect is an integer logical-pixel rectangle in surface coordinates.
45+type Rect struct{ X, Y, Width, Height int32 }
46+
47+// LayerConfig requests a wlr-layer-shell surface instead of an xdg toplevel.
48+// Size still gives the initial logical size; an anchored axis is sized by the
49+// compositor and reported through OnResize and Frame.Size.
50+type LayerConfig struct {
51+ Output string // output name; empty lets the compositor choose
52+ Namespace string // defaults to "nefergui"
53+ Level LayerLevel
54+ Anchors Anchor
55+ Keyboard KeyboardMode
56+ ExclusiveZone int32
57+ Margin [4]int32 // top, right, bottom, left
58+ // InputRects, when non-nil, restricts pointer input to these rectangles.
59+ // An empty non-nil slice makes the surface fully click-through. Rects need
60+ // positive width and height.
61+ InputRects []Rect
62+}
63+
64+// surfaceOptions lowers the config to platform options and validates it.
65+func (c LayerConfig) surfaceOptions() (wayland.SurfaceOptions, error) {
66+ l := wayland.LayerOptions{Output: c.Output, Namespace: c.Namespace, ExclusiveZone: c.ExclusiveZone, Margin: c.Margin}
67+ switch c.Level {
68+ case LayerBackground:
69+ l.Layer = wayland.LayerBackground
70+ case LayerBottom:
71+ l.Layer = wayland.LayerBottom
72+ case LayerTop:
73+ l.Layer = wayland.LayerTop
74+ case LayerOverlay:
75+ l.Layer = wayland.LayerOverlay
76+ default:
77+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid layer level %d", c.Level)
78+ }
79+ if c.Anchors&^anchorAll != 0 {
80+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid layer anchors %#x", uint8(c.Anchors))
81+ }
82+ if c.Anchors&AnchorTop != 0 {
83+ l.Anchor |= wayland.AnchorTop
84+ }
85+ if c.Anchors&AnchorBottom != 0 {
86+ l.Anchor |= wayland.AnchorBottom
87+ }
88+ if c.Anchors&AnchorLeft != 0 {
89+ l.Anchor |= wayland.AnchorLeft
90+ }
91+ if c.Anchors&AnchorRight != 0 {
92+ l.Anchor |= wayland.AnchorRight
93+ }
94+ switch c.Keyboard {
95+ case KeyboardNone:
96+ l.Keyboard = wayland.KeyboardNone
97+ case KeyboardExclusive:
98+ l.Keyboard = wayland.KeyboardExclusive
99+ case KeyboardOnDemand:
100+ l.Keyboard = wayland.KeyboardOnDemand
101+ default:
102+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid keyboard mode %d", c.Keyboard)
103+ }
104+ o := wayland.SurfaceOptions{Layer: &l}
105+ if c.InputRects != nil {
106+ o.InputRects = make([]wayland.Rect, len(c.InputRects))
107+ for i, r := range c.InputRects {
108+ o.InputRects[i] = wayland.Rect{X: r.X, Y: r.Y, W: r.Width, H: r.Height}
109+ }
110+ }
111+ if err := o.Validate(); err != nil {
112+ return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: layer: %w", err)
113+ }
114+ return o, nil
115+}
116+
117+// Layer selects a wlr-layer-shell surface for the window.
118+func Layer(c LayerConfig) WindowOption {
119+ c.InputRects = slices.Clone(c.InputRects)
120+ return windowOption(func(w *windowConfig) { w.layer = &c })
121+}
A internal/ui/rect_debug_test.go +22 -0
@@ -0,0 +1,22 @@
1+//go:build nefergui_debug
2+
3+package ui
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+func TestNodeRectOutsideStackDiagnostic(t *testing.T) {
11+ r := newRuntime()
12+ var diags []string
13+ r.Build(func(f *Frame) {
14+ root := f.Root()
15+ root.Box(Key("bad")).Rect(1, 2, 3, 4)
16+ root.Stack().Box(Key("ok")).Rect(1, 2, 3, 4)
17+ diags = f.Diagnostics()
18+ })
19+ if len(diags) != 1 || !strings.Contains(diags[0], "not a Stack") {
20+ t.Fatalf("diagnostics %q", diags)
21+ }
22+}
@@ -0,0 +1,22 @@
1+//go:build nefergui_debug
2+
3+package ui
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+func TestNodeRectOutsideStackDiagnostic(t *testing.T) {
11+ r := newRuntime()
12+ var diags []string
13+ r.Build(func(f *Frame) {
14+ root := f.Root()
15+ root.Box(Key("bad")).Rect(1, 2, 3, 4)
16+ root.Stack().Box(Key("ok")).Rect(1, 2, 3, 4)
17+ diags = f.Diagnostics()
18+ })
19+ if len(diags) != 1 || !strings.Contains(diags[0], "not a Stack") {
20+ t.Fatalf("diagnostics %q", diags)
21+ }
22+}
M internal/ui/runtime.go +57 -21
@@ -34,24 +34,27 @@ type inputEvent struct {
3434 // runtime serializes frame construction; Queue and Redraw may be called concurrently.
3535 // Re-entrant or concurrent Build requests a follow-up redraw instead of waiting.
3636 type runtime struct {
37- buildMu sync.Mutex
38- mu sync.Mutex
39- pending []inputEvent
40- wake chan struct{}
41- generation uint64
42- committed *element
43- styles *css.Engine
44- output layout.Output
45- state interactionState
46- width, height, scale float64
47- textEngine *text.Engine
48- edits map[string]*edit.State
49- clipboard edit.Clipboard
50- pasteCtx context.Context
51- pasteCancel context.CancelFunc
52- pasteID uint64
53- ime edit.IME
54- redraw bool
37+ buildMu sync.Mutex
38+ mu sync.Mutex
39+ pending []inputEvent
40+ wake chan struct{}
41+ generation uint64
42+ committed *element
43+ styles *css.Engine
44+ output layout.Output
45+ state interactionState
46+ width, height, scale float64
47+ textEngine *text.Engine
48+ edits map[string]*edit.State
49+ clipboard edit.Clipboard
50+ pasteCtx context.Context
51+ pasteCancel context.CancelFunc
52+ pasteID uint64
53+ ime edit.IME
54+ redraw bool
55+ inputRects []Rect
56+ inputOut []wayland.Rect
57+ inputNil, inputStaged bool
5558 }
5659
5760 func newRuntime() *runtime {
@@ -125,8 +128,9 @@ func (r *runtime) Build(view func(*Frame)) bool {
125128 gen := r.generation
126129 previous := r.committed
127130 state := r.state
131+ width, height := r.width, r.height
128132 r.mu.Unlock()
129- f := &Frame{generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}
133+ f := &Frame{width: width, height: height, generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}
130134 defer func() {
131135 f.active = false
132136 r.styles.EndFrame()
@@ -304,13 +308,27 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
304308 return errors.New("nefergui: no usable fonts")
305309 }
306310 r.setTextEngine(text.NewEngine(catalog))
307- s, err := session.Open("", int32(cfg.width), int32(cfg.height), cfg.transparent)
311+ var surface wayland.SurfaceOptions
312+ if cfg.layer != nil {
313+ if surface, err = cfg.layer.surfaceOptions(); err != nil {
314+ return err
315+ }
316+ }
317+ s, err := session.OpenWithOptions(ctx, "", int32(cfg.width), int32(cfg.height), cfg.transparent, surface)
308318 if err != nil {
309319 return fmt.Errorf("nefergui: open platform: %w", err)
310320 }
311321 // Close errors (debug artifact writes) are reported after any earlier error.
312322 defer func() { err = errors.Join(err, s.Close()) }()
313- if err := s.Window.Toplevel.SetTitle(cfg.title); err != nil {
323+ if cfg.onSurface != nil {
324+ if err := startupHook(ctx, cfg.onSurface, WaylandSurface{Display: s.Window.Display, Surface: s.Window.Surface}); err != nil {
325+ if ctx.Err() != nil {
326+ return err
327+ }
328+ return fmt.Errorf("nefergui: surface hook: %w", err)
329+ }
330+ }
331+ if err := s.Window.SetTitle(cfg.title); err != nil { // no-op for layer surfaces
314332 return fmt.Errorf("nefergui: title: %w", err)
315333 }
316334 r.pasteCtx = ctx
@@ -346,6 +364,10 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
346364 }
347365 loopCtx, stop := context.WithCancel(ctx)
348366 defer stop()
367+ if cfg.wake != nil {
368+ go r.forwardWake(loopCtx, cfg.wake)
369+ }
370+ hooks := &inputHooks{onInput: cfg.onInput, onResize: cfg.onResize}
349371 finished := false
350372 err = s.RunApp(loopCtx, session.AppHooks{
351373 Wake: r.wake,
@@ -359,6 +381,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
359381 case input.Kind == "leave":
360382 s.Window.PointerLeft()
361383 }
384+ if hooks.input(input) { // raw hook first; controls still route the event
385+ r.Redraw()
386+ }
362387 r.routeInput(input)
363388 }
364389 // Hover moves without a rebuild; update the cursor from the last
@@ -397,6 +422,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
397422 return nil
398423 },
399424 Draw: func() (render.Frame, bool, error) {
425+ if hooks.resize(int(s.Window.Width), int(s.Window.Height), s.Window.Scale) {
426+ r.Redraw() // an OnResize callback may have changed the model
427+ }
400428 r.route(platformInput{Kind: "resize", Width: float64(s.Window.Width), Height: float64(s.Window.Height), Scale: s.Window.Scale})
401429 if !r.Build(func(f *Frame) { view(f, model) }) {
402430 return render.Frame{}, false, nil
@@ -413,6 +441,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
413441 if ime != nil && ime.Err != nil {
414442 return render.Frame{}, false, ime.Err
415443 }
444+ if err := r.applyInputRects(s.Window.StageInputRects); err != nil {
445+ return render.Frame{}, false, err
446+ }
416447 if err := s.Window.SetCursor(cursorShape(r.cursor())); err != nil {
417448 return render.Frame{}, false, err
418449 }
@@ -435,6 +466,11 @@ type windowConfig struct {
435466 title, styles string
436467 width, height int
437468 transparent bool
469+ layer *LayerConfig
470+ onInput func(InputEvent) bool
471+ onResize func(int, int, float64)
472+ wake <-chan struct{}
473+ onSurface func(context.Context, WaylandSurface) error
438474 }
439475 type windowOption func(*windowConfig)
440476
@@ -34,24 +34,27 @@ type inputEvent struct {
34 // runtime serializes frame construction; Queue and Redraw may be called concurrently.34 // runtime serializes frame construction; Queue and Redraw may be called concurrently.
35 // Re-entrant or concurrent Build requests a follow-up redraw instead of waiting.35 // Re-entrant or concurrent Build requests a follow-up redraw instead of waiting.
36 type runtime struct {36 type runtime struct {
37- buildMu sync.Mutex37+ buildMu sync.Mutex
38- mu sync.Mutex38+ mu sync.Mutex
39- pending []inputEvent39+ pending []inputEvent
40- wake chan struct{}40+ wake chan struct{}
41- generation uint6441+ generation uint64
42- committed *element42+ committed *element
43- styles *css.Engine43+ styles *css.Engine
44- output layout.Output44+ output layout.Output
45- state interactionState45+ state interactionState
46- width, height, scale float6446+ width, height, scale float64
47- textEngine *text.Engine47+ textEngine *text.Engine
48- edits map[string]*edit.State48+ edits map[string]*edit.State
49- clipboard edit.Clipboard49+ clipboard edit.Clipboard
50- pasteCtx context.Context50+ pasteCtx context.Context
51- pasteCancel context.CancelFunc51+ pasteCancel context.CancelFunc
52- pasteID uint6452+ pasteID uint64
53- ime edit.IME53+ ime edit.IME
54- redraw bool54+ redraw bool
55+ inputRects []Rect
56+ inputOut []wayland.Rect
57+ inputNil, inputStaged bool
55 }58 }
56 59
57 func newRuntime() *runtime {60 func newRuntime() *runtime {
@@ -125,8 +128,9 @@ func (r *runtime) Build(view func(*Frame)) bool {
125 gen := r.generation128 gen := r.generation
126 previous := r.committed129 previous := r.committed
127 state := r.state130 state := r.state
131+ width, height := r.width, r.height
128 r.mu.Unlock()132 r.mu.Unlock()
129- f := &Frame{generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}133+ f := &Frame{width: width, height: height, generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}
130 defer func() {134 defer func() {
131 f.active = false135 f.active = false
132 r.styles.EndFrame()136 r.styles.EndFrame()
@@ -304,13 +308,27 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
304 return errors.New("nefergui: no usable fonts")308 return errors.New("nefergui: no usable fonts")
305 }309 }
306 r.setTextEngine(text.NewEngine(catalog))310 r.setTextEngine(text.NewEngine(catalog))
307- s, err := session.Open("", int32(cfg.width), int32(cfg.height), cfg.transparent)311+ var surface wayland.SurfaceOptions
312+ if cfg.layer != nil {
313+ if surface, err = cfg.layer.surfaceOptions(); err != nil {
314+ return err
315+ }
316+ }
317+ s, err := session.OpenWithOptions(ctx, "", int32(cfg.width), int32(cfg.height), cfg.transparent, surface)
308 if err != nil {318 if err != nil {
309 return fmt.Errorf("nefergui: open platform: %w", err)319 return fmt.Errorf("nefergui: open platform: %w", err)
310 }320 }
311 // Close errors (debug artifact writes) are reported after any earlier error.321 // Close errors (debug artifact writes) are reported after any earlier error.
312 defer func() { err = errors.Join(err, s.Close()) }()322 defer func() { err = errors.Join(err, s.Close()) }()
313- if err := s.Window.Toplevel.SetTitle(cfg.title); err != nil {323+ if cfg.onSurface != nil {
324+ if err := startupHook(ctx, cfg.onSurface, WaylandSurface{Display: s.Window.Display, Surface: s.Window.Surface}); err != nil {
325+ if ctx.Err() != nil {
326+ return err
327+ }
328+ return fmt.Errorf("nefergui: surface hook: %w", err)
329+ }
330+ }
331+ if err := s.Window.SetTitle(cfg.title); err != nil { // no-op for layer surfaces
314 return fmt.Errorf("nefergui: title: %w", err)332 return fmt.Errorf("nefergui: title: %w", err)
315 }333 }
316 r.pasteCtx = ctx334 r.pasteCtx = ctx
@@ -346,6 +364,10 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
346 }364 }
347 loopCtx, stop := context.WithCancel(ctx)365 loopCtx, stop := context.WithCancel(ctx)
348 defer stop()366 defer stop()
367+ if cfg.wake != nil {
368+ go r.forwardWake(loopCtx, cfg.wake)
369+ }
370+ hooks := &inputHooks{onInput: cfg.onInput, onResize: cfg.onResize}
349 finished := false371 finished := false
350 err = s.RunApp(loopCtx, session.AppHooks{372 err = s.RunApp(loopCtx, session.AppHooks{
351 Wake: r.wake,373 Wake: r.wake,
@@ -359,6 +381,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
359 case input.Kind == "leave":381 case input.Kind == "leave":
360 s.Window.PointerLeft()382 s.Window.PointerLeft()
361 }383 }
384+ if hooks.input(input) { // raw hook first; controls still route the event
385+ r.Redraw()
386+ }
362 r.routeInput(input)387 r.routeInput(input)
363 }388 }
364 // Hover moves without a rebuild; update the cursor from the last389 // Hover moves without a rebuild; update the cursor from the last
@@ -397,6 +422,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
397 return nil422 return nil
398 },423 },
399 Draw: func() (render.Frame, bool, error) {424 Draw: func() (render.Frame, bool, error) {
425+ if hooks.resize(int(s.Window.Width), int(s.Window.Height), s.Window.Scale) {
426+ r.Redraw() // an OnResize callback may have changed the model
427+ }
400 r.route(platformInput{Kind: "resize", Width: float64(s.Window.Width), Height: float64(s.Window.Height), Scale: s.Window.Scale})428 r.route(platformInput{Kind: "resize", Width: float64(s.Window.Width), Height: float64(s.Window.Height), Scale: s.Window.Scale})
401 if !r.Build(func(f *Frame) { view(f, model) }) {429 if !r.Build(func(f *Frame) { view(f, model) }) {
402 return render.Frame{}, false, nil430 return render.Frame{}, false, nil
@@ -413,6 +441,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
413 if ime != nil && ime.Err != nil {441 if ime != nil && ime.Err != nil {
414 return render.Frame{}, false, ime.Err442 return render.Frame{}, false, ime.Err
415 }443 }
444+ if err := r.applyInputRects(s.Window.StageInputRects); err != nil {
445+ return render.Frame{}, false, err
446+ }
416 if err := s.Window.SetCursor(cursorShape(r.cursor())); err != nil {447 if err := s.Window.SetCursor(cursorShape(r.cursor())); err != nil {
417 return render.Frame{}, false, err448 return render.Frame{}, false, err
418 }449 }
@@ -435,6 +466,11 @@ type windowConfig struct {
435 title, styles string466 title, styles string
436 width, height int467 width, height int
437 transparent bool468 transparent bool
469+ layer *LayerConfig
470+ onInput func(InputEvent) bool
471+ onResize func(int, int, float64)
472+ wake <-chan struct{}
473+ onSurface func(context.Context, WaylandSurface) error
438 }474 }
439 type windowOption func(*windowConfig)475 type windowOption func(*windowConfig)
440 476
M internal/ui/tree.go +39 -13
@@ -31,19 +31,20 @@ func (i *identity) same(j *identity) bool {
3131
3232 // Frame is valid only during one call to the view function.
3333 type Frame struct {
34- generation uint64
35- active bool
36- root *element
37- previous *element
38- events []inputEvent
39- styles *css.Engine
40- edits map[string]*edit.State
41- clipboard edit.Clipboard
42- owner *runtime
43- ime edit.IME
44- state interactionState
45- layout layout.Output
46- diagnostics []string
34+ generation uint64
35+ active bool
36+ root *element
37+ previous *element
38+ events []inputEvent
39+ styles *css.Engine
40+ edits map[string]*edit.State
41+ clipboard edit.Clipboard
42+ owner *runtime
43+ ime edit.IME
44+ state interactionState
45+ layout layout.Output
46+ diagnostics []string
47+ width, height float64
4748 }
4849
4950 // Node is an ephemeral handle into the current frame. Do not retain it between frames.
@@ -71,6 +72,8 @@ type element struct {
7172 imageSize layout.Size
7273 image image.Image
7374 level int
75+ rect layout.Rect // absolute geometry, see Node.Rect
76+ hasRect bool
7477 }
7578
7679 func (n Node) valid() bool {
@@ -181,3 +184,26 @@ func (n Node) Footer(options ...ContainerOption) Node {
181184
182185 // Diagnostics returns a copy of frame diagnostics (populated with -tags nefergui_debug).
183186 func (f *Frame) Diagnostics() []string { return append([]string(nil), f.diagnostics...) }
187+
188+// Size is the logical surface size this frame is laid out against.
189+func (f *Frame) Size() (width, height float64) { return f.width, f.height }
190+
191+// Rect positions the node at x, y with size w, h in logical pixels, relative
192+// to the content box of its Stack parent, without CSS parsing or per-frame
193+// strings or style copies. The geometry is border-box and overrides authored
194+// width, height and margin. Rect is valid only on a direct child of Stack;
195+// elsewhere it is ignored, keeping normal flex and block layout, and debug
196+// builds (-tags nefergui_debug) report it through Frame.Diagnostics.
197+func (n Node) Rect(x, y, w, h float64) Node {
198+ if !n.check() {
199+ return n
200+ }
201+ if p := n.element.parent; p == nil || p.typ != "stack" {
202+ if debugDiagnostics {
203+ n.frame.diagnostics = append(n.frame.diagnostics, "Rect used on a node whose parent is not a Stack")
204+ }
205+ return n
206+ }
207+ n.element.rect, n.element.hasRect = layout.Rect{X: x, Y: y, W: w, H: h}, true
208+ return n
209+}
@@ -31,19 +31,20 @@ func (i *identity) same(j *identity) bool {
31 31
32 // Frame is valid only during one call to the view function.32 // Frame is valid only during one call to the view function.
33 type Frame struct {33 type Frame struct {
34- generation uint6434+ generation uint64
35- active bool35+ active bool
36- root *element36+ root *element
37- previous *element37+ previous *element
38- events []inputEvent38+ events []inputEvent
39- styles *css.Engine39+ styles *css.Engine
40- edits map[string]*edit.State40+ edits map[string]*edit.State
41- clipboard edit.Clipboard41+ clipboard edit.Clipboard
42- owner *runtime42+ owner *runtime
43- ime edit.IME43+ ime edit.IME
44- state interactionState44+ state interactionState
45- layout layout.Output45+ layout layout.Output
46- diagnostics []string46+ diagnostics []string
47+ width, height float64
47 }48 }
48 49
49 // Node is an ephemeral handle into the current frame. Do not retain it between frames.50 // Node is an ephemeral handle into the current frame. Do not retain it between frames.
@@ -71,6 +72,8 @@ type element struct {
71 imageSize layout.Size72 imageSize layout.Size
72 image image.Image73 image image.Image
73 level int74 level int
75+ rect layout.Rect // absolute geometry, see Node.Rect
76+ hasRect bool
74 }77 }
75 78
76 func (n Node) valid() bool {79 func (n Node) valid() bool {
@@ -181,3 +184,26 @@ func (n Node) Footer(options ...ContainerOption) Node {
181 184
182 // Diagnostics returns a copy of frame diagnostics (populated with -tags nefergui_debug).185 // Diagnostics returns a copy of frame diagnostics (populated with -tags nefergui_debug).
183 func (f *Frame) Diagnostics() []string { return append([]string(nil), f.diagnostics...) }186 func (f *Frame) Diagnostics() []string { return append([]string(nil), f.diagnostics...) }
187+
188+// Size is the logical surface size this frame is laid out against.
189+func (f *Frame) Size() (width, height float64) { return f.width, f.height }
190+
191+// Rect positions the node at x, y with size w, h in logical pixels, relative
192+// to the content box of its Stack parent, without CSS parsing or per-frame
193+// strings or style copies. The geometry is border-box and overrides authored
194+// width, height and margin. Rect is valid only on a direct child of Stack;
195+// elsewhere it is ignored, keeping normal flex and block layout, and debug
196+// builds (-tags nefergui_debug) report it through Frame.Diagnostics.
197+func (n Node) Rect(x, y, w, h float64) Node {
198+ if !n.check() {
199+ return n
200+ }
201+ if p := n.element.parent; p == nil || p.typ != "stack" {
202+ if debugDiagnostics {
203+ n.frame.diagnostics = append(n.frame.diagnostics, "Rect used on a node whose parent is not a Stack")
204+ }
205+ return n
206+ }
207+ n.element.rect, n.element.hasRect = layout.Rect{X: x, Y: y, W: w, H: h}, true
208+ return n
209+}
M nefergui.go +63 -0
@@ -103,6 +103,69 @@ func Styles(s string) WindowOption { return ui.Styles(s) }
103103 // Transparent requests an alpha-capable Wayland surface (opaque by default).
104104 func Transparent() WindowOption { return ui.Transparent() }
105105
106+// Layer surface, input and wake configuration. Layer selects a wlr-layer-shell
107+// surface instead of an xdg toplevel; OnInput, OnResize and Wake integrate
108+// callers that own state outside the view. See internal/ui for details.
109+type (
110+ LayerConfig = ui.LayerConfig
111+ LayerLevel = ui.LayerLevel
112+ Anchor = ui.Anchor
113+ KeyboardMode = ui.KeyboardMode
114+ Rect = ui.Rect
115+ InputEvent = ui.InputEvent
116+ InputKind = ui.InputKind
117+ Modifiers = ui.Modifiers
118+)
119+
120+const (
121+ LayerBackground = ui.LayerBackground
122+ LayerBottom = ui.LayerBottom
123+ LayerTop = ui.LayerTop
124+ LayerOverlay = ui.LayerOverlay
125+
126+ AnchorTop = ui.AnchorTop
127+ AnchorBottom = ui.AnchorBottom
128+ AnchorLeft = ui.AnchorLeft
129+ AnchorRight = ui.AnchorRight
130+
131+ KeyboardNone = ui.KeyboardNone
132+ KeyboardExclusive = ui.KeyboardExclusive
133+ KeyboardOnDemand = ui.KeyboardOnDemand
134+
135+ InputPointerMotion = ui.InputPointerMotion
136+ InputPointerPress = ui.InputPointerPress
137+ InputPointerRelease = ui.InputPointerRelease
138+ InputPointerAxis = ui.InputPointerAxis
139+ InputPointerLeave = ui.InputPointerLeave
140+ InputKey = ui.InputKey
141+ InputFocusIn = ui.InputFocusIn
142+ InputFocusOut = ui.InputFocusOut
143+ InputReset = ui.InputReset
144+
145+ ModShift = ui.ModShift
146+ ModCtrl = ui.ModCtrl
147+)
148+
149+// Layer makes the window a layer-shell surface.
150+func Layer(c LayerConfig) WindowOption { return ui.Layer(c) }
151+
152+// OnInput receives raw input on the owner loop before control routing; return
153+// true to request a redraw.
154+func OnInput(fn func(InputEvent) bool) WindowOption { return ui.OnInput(fn) }
155+
156+// OnResize reports logical size and scale at start and on change only.
157+func OnResize(fn func(width, height int, scale float64)) WindowOption { return ui.OnResize(fn) }
158+
159+// WaylandSurface is the window's Wayland display and wl_surface for OnSurface.
160+type WaylandSurface = ui.WaylandSurface
161+
162+// OnSurface runs fn once after the surface role is configured and before the
163+// first buffer; an error aborts Run.
164+func OnSurface(fn func(context.Context, WaylandSurface) error) WindowOption { return ui.OnSurface(fn) }
165+
166+// Wake requests a redraw for each value received on ch.
167+func Wake(ch <-chan struct{}) WindowOption { return ui.Wake(ch) }
168+
106169 // Run builds and presents an immediate view on the Wayland session owner loop.
107170 func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {
108171 return ui.Run(ctx, model, view, options...)
@@ -103,6 +103,69 @@ func Styles(s string) WindowOption { return ui.Styles(s) }
103 // Transparent requests an alpha-capable Wayland surface (opaque by default).103 // Transparent requests an alpha-capable Wayland surface (opaque by default).
104 func Transparent() WindowOption { return ui.Transparent() }104 func Transparent() WindowOption { return ui.Transparent() }
105 105
106+// Layer surface, input and wake configuration. Layer selects a wlr-layer-shell
107+// surface instead of an xdg toplevel; OnInput, OnResize and Wake integrate
108+// callers that own state outside the view. See internal/ui for details.
109+type (
110+ LayerConfig = ui.LayerConfig
111+ LayerLevel = ui.LayerLevel
112+ Anchor = ui.Anchor
113+ KeyboardMode = ui.KeyboardMode
114+ Rect = ui.Rect
115+ InputEvent = ui.InputEvent
116+ InputKind = ui.InputKind
117+ Modifiers = ui.Modifiers
118+)
119+
120+const (
121+ LayerBackground = ui.LayerBackground
122+ LayerBottom = ui.LayerBottom
123+ LayerTop = ui.LayerTop
124+ LayerOverlay = ui.LayerOverlay
125+
126+ AnchorTop = ui.AnchorTop
127+ AnchorBottom = ui.AnchorBottom
128+ AnchorLeft = ui.AnchorLeft
129+ AnchorRight = ui.AnchorRight
130+
131+ KeyboardNone = ui.KeyboardNone
132+ KeyboardExclusive = ui.KeyboardExclusive
133+ KeyboardOnDemand = ui.KeyboardOnDemand
134+
135+ InputPointerMotion = ui.InputPointerMotion
136+ InputPointerPress = ui.InputPointerPress
137+ InputPointerRelease = ui.InputPointerRelease
138+ InputPointerAxis = ui.InputPointerAxis
139+ InputPointerLeave = ui.InputPointerLeave
140+ InputKey = ui.InputKey
141+ InputFocusIn = ui.InputFocusIn
142+ InputFocusOut = ui.InputFocusOut
143+ InputReset = ui.InputReset
144+
145+ ModShift = ui.ModShift
146+ ModCtrl = ui.ModCtrl
147+)
148+
149+// Layer makes the window a layer-shell surface.
150+func Layer(c LayerConfig) WindowOption { return ui.Layer(c) }
151+
152+// OnInput receives raw input on the owner loop before control routing; return
153+// true to request a redraw.
154+func OnInput(fn func(InputEvent) bool) WindowOption { return ui.OnInput(fn) }
155+
156+// OnResize reports logical size and scale at start and on change only.
157+func OnResize(fn func(width, height int, scale float64)) WindowOption { return ui.OnResize(fn) }
158+
159+// WaylandSurface is the window's Wayland display and wl_surface for OnSurface.
160+type WaylandSurface = ui.WaylandSurface
161+
162+// OnSurface runs fn once after the surface role is configured and before the
163+// first buffer; an error aborts Run.
164+func OnSurface(fn func(context.Context, WaylandSurface) error) WindowOption { return ui.OnSurface(fn) }
165+
166+// Wake requests a redraw for each value received on ch.
167+func Wake(ch <-chan struct{}) WindowOption { return ui.Wake(ch) }
168+
106 // Run builds and presents an immediate view on the Wayland session owner loop.169 // Run builds and presents an immediate view on the Wayland session owner loop.
107 func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {170 func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {
108 return ui.Run(ctx, model, view, options...)171 return ui.Run(ctx, model, view, options...)

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 1

Tags 12

↑↓ moveenter openesc close