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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 105 | 105 | ScrollX, ScrollY float64 | |
| 106 | 106 | NoWrap bool // measure text at unbounded width; useful for single-line editors | |
| 107 | 107 | 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 | 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 | 317 | if child == nil || style(child).Display == css.KeywordNone { | |
| 313 | 318 | 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 | 326 | cs := style(child) | |
| 316 | 327 | inW, inH := flexInsets(cs, w) | |
| 317 | 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 | 541 | if ch == nil { | |
| 531 | 542 | 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 | 556 | if err != nil { | |
| 535 | 557 | return err | |
| 536 | 558 | } | |
| @@ -538,6 +560,9 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error { | |||
| 538 | 560 | continue | |
| 539 | 561 | } | |
| 540 | 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 | 566 | r.Children = append(r.Children, child) | |
| 542 | 567 | r.ContentSize.W = math.Max(r.ContentSize.W, cw) | |
| 543 | 568 | if n.Kind == Stack { | |
| @@ -105,6 +105,11 @@ type Node struct { | |||||
| 105 | ScrollX, ScrollY float64 | 105 | ScrollX, ScrollY float64 | ||
| 106 | NoWrap bool // measure text at unbounded width; useful for single-line editors | 106 | 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 scrolling | 107 | 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 | continue | 318 | 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 | continue | 542 | 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 err | 557 | return err | ||
| 536 | } | 558 | } | ||
| @@ -538,6 +560,9 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error { | |||||
| 538 | continue | 560 | 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) { | |||
| 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 | + | } | |
| @@ -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 ( | |||
| 35 | 35 | FeedbackFormats | |
| 36 | 36 | ClipboardInput | |
| 37 | 37 | IMEInput | |
| 38 | + | // ConfigureLayer carries a layer-surface extent; zero axes are unchanged. | |
| 39 | + | ConfigureLayer | |
| 38 | 40 | ) | |
| 39 | 41 | ||
| 40 | 42 | type Event struct { | |
| @@ -116,10 +118,21 @@ func (w *Window) Apply(ev Event) error { | |||
| 116 | 118 | w.Width, w.Height, w.Dirty = ev.Width, ev.Height, true | |
| 117 | 119 | } | |
| 118 | 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 | 131 | return err | |
| 121 | 132 | } | |
| 122 | 133 | w.Configured, w.FrameReady = true, true | |
| 134 | + | case ConfigureLayer: | |
| 135 | + | w.applyLayerSize(ev.Width, ev.Height) | |
| 123 | 136 | case PreferredScale: | |
| 124 | 137 | if ev.Scale == 0 { | |
| 125 | 138 | return fmt.Errorf("invalid preferred scale zero") | |
| @@ -35,6 +35,8 @@ const ( | |||||
| 35 | FeedbackFormats | 35 | FeedbackFormats | ||
| 36 | ClipboardInput | 36 | ClipboardInput | ||
| 37 | IMEInput | 37 | 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, true | 118 | 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 err | 131 | return err | ||
| 121 | } | 132 | } | ||
| 122 | w.Configured, w.FrameReady = true, true | 133 | 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) { | |||
| 29 | 29 | t.Fatal(err) | |
| 30 | 30 | } | |
| 31 | 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 | 33 | if code != harness.Pass { | |
| 34 | 34 | t.Fatalf("harness code=%d status=%s error=%s artifacts=%s", code, result.Status, result.Error, root) | |
| 35 | 35 | } | |
| @@ -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 @@ | |||
| 6 | 6 | package wayland | |
| 7 | 7 | ||
| 8 | 8 | import ( | |
| 9 | + | "context" | |
| 9 | 10 | "encoding/binary" | |
| 10 | 11 | "errors" | |
| 11 | 12 | "fmt" | |
| 12 | 13 | "math" | |
| 14 | + | "net" | |
| 15 | + | "os" | |
| 16 | + | "path/filepath" | |
| 13 | 17 | "sync" | |
| 14 | 18 | "sync/atomic" | |
| 19 | + | "time" | |
| 15 | 20 | ||
| 16 | 21 | "github.com/bnema/wlturbo" | |
| 17 | 22 | "github.com/bnema/wlturbo/protocol/core" | |
| @@ -22,6 +27,8 @@ import ( | |||
| 22 | 27 | "github.com/bnema/wlturbo/protocol/viewporter" | |
| 23 | 28 | "github.com/bnema/wlturbo/protocol/xdgshell" | |
| 24 | 29 | "github.com/bnema/wlturbo/wl" | |
| 30 | + | ||
| 31 | + | "github.com/bnema/nefergui/internal/platform/wayland/layershell" | |
| 25 | 32 | ) | |
| 26 | 33 | ||
| 27 | 34 | // CapabilityError reports a missing or insufficient required protocol. | |
| @@ -46,6 +53,8 @@ type Window struct { | |||
| 46 | 53 | Shell *xdgshell.XdgWmBase | |
| 47 | 54 | Dmabuf *linuxdmabuf.LinuxDmabuf | |
| 48 | 55 | SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager | |
| 56 | + | LayerShell *layershell.LayerShell // nil for xdg windows | |
| 57 | + | LayerSurface *layershell.LayerSurface // nil for xdg windows | |
| 49 | 58 | Surface *core.Surface | |
| 50 | 59 | XdgSurface *xdgshell.XdgSurface | |
| 51 | 60 | Toplevel *xdgshell.XdgToplevel | |
| @@ -79,28 +88,94 @@ type Window struct { | |||
| 79 | 88 | readerStarted atomic.Bool | |
| 80 | 89 | inputAdapter *InputAdapter | |
| 81 | 90 | 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 | 96 | // InputOverflow counts events discarded when the 4096-event queue fills. | |
| 83 | 97 | 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 | 136 | if width <= 0 || height <= 0 { | |
| 88 | 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 | 146 | if err != nil { | |
| 92 | 147 | 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 | 160 | w := &Window{events: make(chan Event, 128), readerStop: make(chan struct{}), Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent} | |
| 95 | 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 | 167 | if err != nil { | |
| 168 | + | if cerr := ctx.Err(); cerr != nil && !errors.Is(err, cerr) { | |
| 169 | + | err = errors.Join(cerr, err) | |
| 170 | + | } | |
| 97 | 171 | _ = w.Close() | |
| 172 | + | res = nil | |
| 98 | 173 | } | |
| 99 | 174 | }() | |
| 100 | 175 | if err = d.Roundtrip(); err != nil { | |
| 101 | 176 | return nil, err | |
| 102 | 177 | } | |
| 103 | - | ctx := d.Context() | |
| 178 | + | wctx := d.Context() | |
| 104 | 179 | bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error { | |
| 105 | 180 | advertised, ok := d.Registry().FindGlobal(name) | |
| 106 | 181 | if !ok || advertised.Version < minimum { | |
| @@ -115,54 +190,62 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err | |||
| 115 | 190 | } | |
| 116 | 191 | return nil | |
| 117 | 192 | } | |
| 118 | - | w.Compositor = core.NewCompositor(ctx) | |
| 193 | + | w.Compositor = core.NewCompositor(wctx) | |
| 119 | 194 | if err = bind(core.CompositorInterface, 6, 4, w.Compositor); err != nil { | |
| 120 | 195 | return nil, err | |
| 121 | 196 | } | |
| 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 | + | } | |
| 125 | 206 | } | |
| 126 | - | w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(ctx) | |
| 207 | + | w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(wctx) | |
| 127 | 208 | if err = bind(linuxdmabuf.LinuxDmabufInterface, 4, 4, w.Dmabuf); err != nil { | |
| 128 | 209 | return nil, err | |
| 129 | 210 | } | |
| 130 | - | w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(ctx) | |
| 211 | + | w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(wctx) | |
| 131 | 212 | if err = bind(drmsyncobj.WpLinuxDrmSyncobjManagerInterface, 1, 1, w.SyncManager); err != nil { | |
| 132 | 213 | return nil, err | |
| 133 | 214 | } | |
| 134 | - | w.Viewporter = viewporter.NewWpViewporter(ctx) | |
| 215 | + | w.Viewporter = viewporter.NewWpViewporter(wctx) | |
| 135 | 216 | if _, e := d.Registry().BindNegotiated(viewporter.WpViewporterInterface, 1, w.Viewporter); e != nil { | |
| 136 | 217 | if !errors.Is(e, wlturbo.ErrGlobalNotFound) { | |
| 137 | 218 | return nil, e | |
| 138 | 219 | } | |
| 139 | - | ctx.Unregister(w.Viewporter) | |
| 220 | + | wctx.Unregister(w.Viewporter) | |
| 140 | 221 | w.Viewporter = nil | |
| 141 | 222 | } | |
| 142 | - | w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(ctx) | |
| 223 | + | w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(wctx) | |
| 143 | 224 | if _, e := d.Registry().BindNegotiated(fractionalscale.WpFractionalScaleManagerInterface, 1, w.ScaleManager); e != nil { | |
| 144 | 225 | if !errors.Is(e, wlturbo.ErrGlobalNotFound) { | |
| 145 | 226 | return nil, e | |
| 146 | 227 | } | |
| 147 | - | ctx.Unregister(w.ScaleManager) | |
| 228 | + | wctx.Unregister(w.ScaleManager) | |
| 148 | 229 | w.ScaleManager = nil | |
| 149 | 230 | } | |
| 150 | - | w.CursorShapes = cursorshape.NewWpCursorShapeManager(ctx) | |
| 231 | + | w.CursorShapes = cursorshape.NewWpCursorShapeManager(wctx) | |
| 151 | 232 | if _, e := d.Registry().BindNegotiated(cursorshape.WpCursorShapeManagerInterface, 1, w.CursorShapes); e != nil { | |
| 152 | 233 | if !errors.Is(e, wlturbo.ErrGlobalNotFound) { | |
| 153 | 234 | return nil, e | |
| 154 | 235 | } | |
| 155 | - | ctx.Unregister(w.CursorShapes) | |
| 236 | + | wctx.Unregister(w.CursorShapes) | |
| 156 | 237 | w.CursorShapes = nil | |
| 157 | 238 | } | |
| 158 | 239 | if err = w.BindSeat(); err != nil { | |
| 159 | 240 | return nil, err | |
| 160 | 241 | } | |
| 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 | + | } | |
| 166 | 249 | w.Surface, err = w.Compositor.CreateSurface() | |
| 167 | 250 | if err != nil { | |
| 168 | 251 | return nil, err | |
| @@ -207,14 +290,51 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err | |||
| 207 | 290 | 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 | 306 | if err != nil { | |
| 212 | 307 | return nil, err | |
| 213 | 308 | } | |
| 214 | - | w.Toplevel, err = w.XdgSurface.GetToplevel() | |
| 309 | + | w.Feedback, err = w.Dmabuf.GetDefaultFeedback() | |
| 215 | 310 | if err != nil { | |
| 216 | 311 | 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 | 338 | w.Toplevel.OnClose(func() { | |
| 219 | 339 | if w.readerStarted.Load() { | |
| 220 | 340 | w.post(Event{Kind: CloseEvent}) | |
| @@ -242,27 +362,7 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err | |||
| 242 | 362 | } | |
| 243 | 363 | w.Configured, w.FrameReady = true, true | |
| 244 | 364 | }) | |
| 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 | |
| 266 | 366 | } | |
| 267 | 367 | ||
| 268 | 368 | func (w *Window) Dispatch() error { | |
| @@ -399,6 +499,7 @@ func (w *Window) Close() error { | |||
| 399 | 499 | _ = w.SyncSurface.Destroy() | |
| 400 | 500 | w.SyncSurface = nil | |
| 401 | 501 | } | |
| 502 | + | w.closeLayer() // before wl_surface | |
| 402 | 503 | if w.Toplevel != nil { | |
| 403 | 504 | _ = w.Toplevel.Destroy() | |
| 404 | 505 | w.Toplevel = nil | |
| @@ -6,12 +6,17 @@ | |||||
| 6 | package wayland | 6 | 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.XdgWmBase | 53 | Shell *xdgshell.XdgWmBase | ||
| 47 | Dmabuf *linuxdmabuf.LinuxDmabuf | 54 | Dmabuf *linuxdmabuf.LinuxDmabuf | ||
| 48 | SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager | 55 | SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager | ||
| 56 | + | LayerShell *layershell.LayerShell // nil for xdg windows | |||
| 57 | + | LayerSurface *layershell.LayerSurface // nil for xdg windows | |||
| 49 | Surface *core.Surface | 58 | Surface *core.Surface | ||
| 50 | XdgSurface *xdgshell.XdgSurface | 59 | XdgSurface *xdgshell.XdgSurface | ||
| 51 | Toplevel *xdgshell.XdgToplevel | 60 | Toplevel *xdgshell.XdgToplevel | ||
| @@ -79,28 +88,94 @@ type Window struct { | |||||
| 79 | readerStarted atomic.Bool | 88 | readerStarted atomic.Bool | ||
| 80 | inputAdapter *InputAdapter | 89 | inputAdapter *InputAdapter | ||
| 81 | inputQueue []InputEvent | 90 | 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 uint64 | 97 | 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, err | 147 | 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, err | 176 | 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 nil | 191 | 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, err | 195 | 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, err | 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 | + | } | |||
| 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, err | 209 | 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, err | 213 | 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, e | 218 | return nil, e | ||
| 138 | } | 219 | } | ||
| 139 | - | ctx.Unregister(w.Viewporter) | 220 | + | wctx.Unregister(w.Viewporter) |
| 140 | w.Viewporter = nil | 221 | 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, e | 226 | return nil, e | ||
| 146 | } | 227 | } | ||
| 147 | - | ctx.Unregister(w.ScaleManager) | 228 | + | wctx.Unregister(w.ScaleManager) |
| 148 | w.ScaleManager = nil | 229 | 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, e | 234 | return nil, e | ||
| 154 | } | 235 | } | ||
| 155 | - | ctx.Unregister(w.CursorShapes) | 236 | + | wctx.Unregister(w.CursorShapes) |
| 156 | w.CursorShapes = nil | 237 | w.CursorShapes = nil | ||
| 157 | } | 238 | } | ||
| 158 | if err = w.BindSeat(); err != nil { | 239 | if err = w.BindSeat(); err != nil { | ||
| 159 | return nil, err | 240 | 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, err | 251 | return nil, err | ||
| @@ -207,14 +290,51 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err | |||||
| 207 | w.Dirty = true | 290 | 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, err | 307 | 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, err | 311 | 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, true | 363 | 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 = nil | 500 | 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 = nil | 505 | w.Toplevel = nil | ||
M internal/presentation/session/schedule_test.go +2 -1
| @@ -48,7 +48,8 @@ func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) { | |||
| 48 | 48 | t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true)) | |
| 49 | 49 | } | |
| 50 | 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 | 53 | t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits) | |
| 53 | 54 | } | |
| 54 | 55 | 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 = false | 55 | h.blocked = false | ||
M internal/presentation/session/session.go +10 -2
| @@ -152,8 +152,16 @@ func (s *Session) traceState(b *buffers.Buffer, event string) { | |||
| 152 | 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) { | |
| 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) | |
| 157 | 165 | if err != nil { | |
| 158 | 166 | return nil, err | |
| 159 | 167 | } | |
| @@ -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, err | 166 | 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 { | |||
| 107 | 107 | if e.computed != nil { | |
| 108 | 108 | n.Style = e.computed.Style | |
| 109 | 109 | } | |
| 110 | + | n.Rect, n.HasRect = e.rect, e.hasRect | |
| 110 | 111 | if offset, ok := r.state.scroll[n.ID]; ok { | |
| 111 | 112 | n.ScrollX, n.ScrollY = offset.W, offset.H | |
| 112 | 113 | } | |
| @@ -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.Style | 108 | 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.H | 112 | 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 { | |||
| 34 | 34 | // runtime serializes frame construction; Queue and Redraw may be called concurrently. | |
| 35 | 35 | // Re-entrant or concurrent Build requests a follow-up redraw instead of waiting. | |
| 36 | 36 | 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 | |
| 55 | 58 | } | |
| 56 | 59 | ||
| 57 | 60 | func newRuntime() *runtime { | |
| @@ -125,8 +128,9 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 125 | 128 | gen := r.generation | |
| 126 | 129 | previous := r.committed | |
| 127 | 130 | state := r.state | |
| 131 | + | width, height := r.width, r.height | |
| 128 | 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 | 134 | defer func() { | |
| 131 | 135 | f.active = false | |
| 132 | 136 | r.styles.EndFrame() | |
| @@ -304,13 +308,27 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), | |||
| 304 | 308 | return errors.New("nefergui: no usable fonts") | |
| 305 | 309 | } | |
| 306 | 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 | 318 | if err != nil { | |
| 309 | 319 | return fmt.Errorf("nefergui: open platform: %w", err) | |
| 310 | 320 | } | |
| 311 | 321 | // Close errors (debug artifact writes) are reported after any earlier error. | |
| 312 | 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 | 332 | return fmt.Errorf("nefergui: title: %w", err) | |
| 315 | 333 | } | |
| 316 | 334 | r.pasteCtx = ctx | |
| @@ -346,6 +364,10 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), | |||
| 346 | 364 | } | |
| 347 | 365 | loopCtx, stop := context.WithCancel(ctx) | |
| 348 | 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 | 371 | finished := false | |
| 350 | 372 | err = s.RunApp(loopCtx, session.AppHooks{ | |
| 351 | 373 | Wake: r.wake, | |
| @@ -359,6 +381,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), | |||
| 359 | 381 | case input.Kind == "leave": | |
| 360 | 382 | s.Window.PointerLeft() | |
| 361 | 383 | } | |
| 384 | + | if hooks.input(input) { // raw hook first; controls still route the event | |
| 385 | + | r.Redraw() | |
| 386 | + | } | |
| 362 | 387 | r.routeInput(input) | |
| 363 | 388 | } | |
| 364 | 389 | // 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 | 422 | return nil | |
| 398 | 423 | }, | |
| 399 | 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 | 428 | r.route(platformInput{Kind: "resize", Width: float64(s.Window.Width), Height: float64(s.Window.Height), Scale: s.Window.Scale}) | |
| 401 | 429 | if !r.Build(func(f *Frame) { view(f, model) }) { | |
| 402 | 430 | return render.Frame{}, false, nil | |
| @@ -413,6 +441,9 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), | |||
| 413 | 441 | if ime != nil && ime.Err != nil { | |
| 414 | 442 | 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 | 447 | if err := s.Window.SetCursor(cursorShape(r.cursor())); err != nil { | |
| 417 | 448 | return render.Frame{}, false, err | |
| 418 | 449 | } | |
| @@ -435,6 +466,11 @@ type windowConfig struct { | |||
| 435 | 466 | title, styles string | |
| 436 | 467 | width, height int | |
| 437 | 468 | 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 | 475 | type windowOption func(*windowConfig) | |
| 440 | 476 | ||
| @@ -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.Mutex | 37 | + | buildMu sync.Mutex |
| 38 | - | mu sync.Mutex | 38 | + | mu sync.Mutex |
| 39 | - | pending []inputEvent | 39 | + | pending []inputEvent |
| 40 | - | wake chan struct{} | 40 | + | wake chan struct{} |
| 41 | - | generation uint64 | 41 | + | generation uint64 |
| 42 | - | committed *element | 42 | + | committed *element |
| 43 | - | styles *css.Engine | 43 | + | styles *css.Engine |
| 44 | - | output layout.Output | 44 | + | output layout.Output |
| 45 | - | state interactionState | 45 | + | state interactionState |
| 46 | - | width, height, scale float64 | 46 | + | width, height, scale float64 |
| 47 | - | textEngine *text.Engine | 47 | + | textEngine *text.Engine |
| 48 | - | edits map[string]*edit.State | 48 | + | edits map[string]*edit.State |
| 49 | - | clipboard edit.Clipboard | 49 | + | clipboard edit.Clipboard |
| 50 | - | pasteCtx context.Context | 50 | + | pasteCtx context.Context |
| 51 | - | pasteCancel context.CancelFunc | 51 | + | pasteCancel context.CancelFunc |
| 52 | - | pasteID uint64 | 52 | + | pasteID uint64 |
| 53 | - | ime edit.IME | 53 | + | ime edit.IME |
| 54 | - | redraw bool | 54 | + | 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.generation | 128 | gen := r.generation | ||
| 126 | previous := r.committed | 129 | previous := r.committed | ||
| 127 | state := r.state | 130 | 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 = false | 135 | 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 = ctx | 334 | 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 := false | 371 | 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 last | 389 | // 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 nil | 422 | 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, nil | 430 | 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.Err | 442 | 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, err | 448 | return render.Frame{}, false, err | ||
| 418 | } | 449 | } | ||
| @@ -435,6 +466,11 @@ type windowConfig struct { | |||||
| 435 | title, styles string | 466 | title, styles string | ||
| 436 | width, height int | 467 | width, height int | ||
| 437 | transparent bool | 468 | 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 { | |||
| 31 | 31 | ||
| 32 | 32 | // Frame is valid only during one call to the view function. | |
| 33 | 33 | 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 | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | // Node is an ephemeral handle into the current frame. Do not retain it between frames. | |
| @@ -71,6 +72,8 @@ type element struct { | |||
| 71 | 72 | imageSize layout.Size | |
| 72 | 73 | image image.Image | |
| 73 | 74 | level int | |
| 75 | + | rect layout.Rect // absolute geometry, see Node.Rect | |
| 76 | + | hasRect bool | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | 79 | func (n Node) valid() bool { | |
| @@ -181,3 +184,26 @@ func (n Node) Footer(options ...ContainerOption) Node { | |||
| 181 | 184 | ||
| 182 | 185 | // Diagnostics returns a copy of frame diagnostics (populated with -tags nefergui_debug). | |
| 183 | 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 | + | } | |
| @@ -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 uint64 | 34 | + | generation uint64 |
| 35 | - | active bool | 35 | + | active bool |
| 36 | - | root *element | 36 | + | root *element |
| 37 | - | previous *element | 37 | + | previous *element |
| 38 | - | events []inputEvent | 38 | + | events []inputEvent |
| 39 | - | styles *css.Engine | 39 | + | styles *css.Engine |
| 40 | - | edits map[string]*edit.State | 40 | + | edits map[string]*edit.State |
| 41 | - | clipboard edit.Clipboard | 41 | + | clipboard edit.Clipboard |
| 42 | - | owner *runtime | 42 | + | owner *runtime |
| 43 | - | ime edit.IME | 43 | + | ime edit.IME |
| 44 | - | state interactionState | 44 | + | state interactionState |
| 45 | - | layout layout.Output | 45 | + | layout layout.Output |
| 46 | - | diagnostics []string | 46 | + | 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.Size | 72 | imageSize layout.Size | ||
| 72 | image image.Image | 73 | image image.Image | ||
| 73 | level int | 74 | 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) } | |||
| 103 | 103 | // Transparent requests an alpha-capable Wayland surface (opaque by default). | |
| 104 | 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 | 169 | // Run builds and presents an immediate view on the Wayland session owner loop. | |
| 107 | 170 | func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error { | |
| 108 | 171 | 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...) | ||