feat(layer): add LayerConfig.AllOutputs for one surface per output
Run with AllOutputs shows the same view and model on one layer surface per output, on a single Wayland connection and owner loop. Outputs added later gain a surface and removed ones lose theirs. A build that handled control events redraws the other outputs. LayerOptions.OutputGlobal binds a layer surface to a connection-owned output, and the harness accepts several comma-separated headless output sizes.
- commit
- 8bbc3559031e3a959f3412fe7a65e09d3256594e
- author
- brice · · 2026-10-01 04:01 UTC
- parent
- 6800c6a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +490 -10
M docs/harness.md +1 -1
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | `go run ./cmd/nefergui-harness run --size 800x600 --scale 1.5 --layout fr --input script.txt --expect expect.json --out artifacts -- <client> [args...]` | |
| 4 | 4 | ||
| 5 | - | Omit `-- <client>` for a compositor-only run. The output directory must not exist. `--background '#336699'` sets the compositor background (default `#111111`). `--timeout 15s` limits the whole run, including the build (which is separately capped at three minutes); `--ready-timeout 8s` bounds socket discovery. `--keep` preserves the isolated runtime/config directory and records its location in `result.json`. | |
| 5 | + | `--size 800x600,320x200` starts one headless output per size (`HEADLESS-1`, `HEADLESS-2`, ...); screenshots and expectations then use the first output, in `frames/HEADLESS-1/`. Omit `-- <client>` for a compositor-only run. The output directory must not exist. `--background '#336699'` sets the compositor background (default `#111111`). `--timeout 15s` limits the whole run, including the build (which is separately capped at three minutes); `--ready-timeout 8s` bounds socket discovery. `--keep` preserves the isolated runtime/config directory and records its location in `result.json`. | |
| 6 | 6 | ||
| 7 | 7 | The harness installs NeferWL from pinned commit `1d16b83bf90f9e8189ad21b27772382c96b9f9a8` into `$XDG_CACHE_HOME/nefergui/harness/neferwl-<sha>/` on first use. `NEFERGUI_NEFERWL` selects an existing executable instead, but requires `--allow-unpinned`; this records `compositor.pinned=false` and `compositor.binary` in `result.json`. Pinned runs record `compositor.pinned=true` and `compositor.sha`. Installation uses `GOWORK=off`, a remote module, and no local replacement. A host Vulkan GPU and working NeferWL runtime are needed for integration. | |
| 8 | 8 | ||
| @@ -2,7 +2,7 @@ | |||||
| 2 | 2 | ||||
| 3 | `go run ./cmd/nefergui-harness run --size 800x600 --scale 1.5 --layout fr --input script.txt --expect expect.json --out artifacts -- <client> [args...]` | 3 | `go run ./cmd/nefergui-harness run --size 800x600 --scale 1.5 --layout fr --input script.txt --expect expect.json --out artifacts -- <client> [args...]` | ||
| 4 | 4 | ||||
| 5 | - | Omit `-- <client>` for a compositor-only run. The output directory must not exist. `--background '#336699'` sets the compositor background (default `#111111`). `--timeout 15s` limits the whole run, including the build (which is separately capped at three minutes); `--ready-timeout 8s` bounds socket discovery. `--keep` preserves the isolated runtime/config directory and records its location in `result.json`. | 5 | + | `--size 800x600,320x200` starts one headless output per size (`HEADLESS-1`, `HEADLESS-2`, ...); screenshots and expectations then use the first output, in `frames/HEADLESS-1/`. Omit `-- <client>` for a compositor-only run. The output directory must not exist. `--background '#336699'` sets the compositor background (default `#111111`). `--timeout 15s` limits the whole run, including the build (which is separately capped at three minutes); `--ready-timeout 8s` bounds socket discovery. `--keep` preserves the isolated runtime/config directory and records its location in `result.json`. |
| 6 | 6 | ||||
| 7 | The harness installs NeferWL from pinned commit `1d16b83bf90f9e8189ad21b27772382c96b9f9a8` into `$XDG_CACHE_HOME/nefergui/harness/neferwl-<sha>/` on first use. `NEFERGUI_NEFERWL` selects an existing executable instead, but requires `--allow-unpinned`; this records `compositor.pinned=false` and `compositor.binary` in `result.json`. Pinned runs record `compositor.pinned=true` and `compositor.sha`. Installation uses `GOWORK=off`, a remote module, and no local replacement. A host Vulkan GPU and working NeferWL runtime are needed for integration. | 7 | The harness installs NeferWL from pinned commit `1d16b83bf90f9e8189ad21b27772382c96b9f9a8` into `$XDG_CACHE_HOME/nefergui/harness/neferwl-<sha>/` on first use. `NEFERGUI_NEFERWL` selects an existing executable instead, but requires `--allow-unpinned`; this records `compositor.pinned=false` and `compositor.binary` in `result.json`. Pinned runs record `compositor.pinned=true` and `compositor.sha`. Installation uses `GOWORK=off`, a remote module, and no local replacement. A host Vulkan GPU and working NeferWL runtime are needed for integration. | ||
| 8 | 8 | ||||
M docs/runtime.md +2 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | `Run(ctx, &model, view, options...)` opens a Wayland surface, renders with Vulkan and returns when the surface closes (`nil`) or `ctx` is cancelled. It uses an xdg toplevel by default; `Layer(LayerConfig)` chooses a layer-shell role with an output name, layer, anchors, margins, exclusive zone and keyboard mode. `Title`, `Size` (logical pixels), `Styles` (CSS file path) and `Transparent` configure the surface. Setup and teardown errors are returned; joined errors retain real failures alongside cancellation. Accessibility is not connected. | |
| 4 | 4 | ||
| 5 | + | `LayerConfig.AllOutputs` puts one layer surface on every output through one Wayland connection and owner loop. Outputs added later gain a surface; removed outputs lose theirs. Every surface runs the same view and model, laid out at its own size and scale; `Wake` and `OnInput` returning true redraw them all. `Run` then returns only on cancellation or failure. It cannot be combined with `Output`, keyboard interactivity, `OnSurface`, `OnResize` or `RunFrames`. | |
| 6 | + | ||
| 5 | 7 | `OnInput(func(InputEvent) bool)` observes input on the owner loop; return true only when application state changed and needs a redraw. `OnResize` runs at startup and on size or scale changes. `Wake` requests an event-driven redraw without a continuous frame loop. `OnSurface(func(context.Context, WaylandSurface) error)` configures extension protocols after the surface role exists and before the first buffer. Its display and surface belong to NeferGUI. Protocol callbacks run on the owner during startup and on the reader after the loop starts; callbacks must not mutate the application's model from the reader. | |
| 6 | 8 | ||
| 7 | 9 | `Frame.SetInputRects` stages the clickable region with the frame's buffer commit. Nil restores the whole surface; a non-nil empty slice makes it click-through. `Node.Rect` places a border-box rectangle relative to a Stack's content box without parsing geometry CSS. It applies only to Stack children; debug builds diagnose other parents. | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | `Run(ctx, &model, view, options...)` opens a Wayland surface, renders with Vulkan and returns when the surface closes (`nil`) or `ctx` is cancelled. It uses an xdg toplevel by default; `Layer(LayerConfig)` chooses a layer-shell role with an output name, layer, anchors, margins, exclusive zone and keyboard mode. `Title`, `Size` (logical pixels), `Styles` (CSS file path) and `Transparent` configure the surface. Setup and teardown errors are returned; joined errors retain real failures alongside cancellation. Accessibility is not connected. | 3 | `Run(ctx, &model, view, options...)` opens a Wayland surface, renders with Vulkan and returns when the surface closes (`nil`) or `ctx` is cancelled. It uses an xdg toplevel by default; `Layer(LayerConfig)` chooses a layer-shell role with an output name, layer, anchors, margins, exclusive zone and keyboard mode. `Title`, `Size` (logical pixels), `Styles` (CSS file path) and `Transparent` configure the surface. Setup and teardown errors are returned; joined errors retain real failures alongside cancellation. Accessibility is not connected. | ||
| 4 | 4 | ||||
| 5 | + | `LayerConfig.AllOutputs` puts one layer surface on every output through one Wayland connection and owner loop. Outputs added later gain a surface; removed outputs lose theirs. Every surface runs the same view and model, laid out at its own size and scale; `Wake` and `OnInput` returning true redraw them all. `Run` then returns only on cancellation or failure. It cannot be combined with `Output`, keyboard interactivity, `OnSurface`, `OnResize` or `RunFrames`. | |||
| 6 | + | ||||
| 5 | `OnInput(func(InputEvent) bool)` observes input on the owner loop; return true only when application state changed and needs a redraw. `OnResize` runs at startup and on size or scale changes. `Wake` requests an event-driven redraw without a continuous frame loop. `OnSurface(func(context.Context, WaylandSurface) error)` configures extension protocols after the surface role exists and before the first buffer. Its display and surface belong to NeferGUI. Protocol callbacks run on the owner during startup and on the reader after the loop starts; callbacks must not mutate the application's model from the reader. | 7 | `OnInput(func(InputEvent) bool)` observes input on the owner loop; return true only when application state changed and needs a redraw. `OnResize` runs at startup and on size or scale changes. `Wake` requests an event-driven redraw without a continuous frame loop. `OnSurface(func(context.Context, WaylandSurface) error)` configures extension protocols after the surface role exists and before the first buffer. Its display and surface belong to NeferGUI. Protocol callbacks run on the owner during startup and on the reader after the loop starts; callbacks must not mutate the application's model from the reader. | ||
| 6 | 8 | ||||
| 7 | `Frame.SetInputRects` stages the clickable region with the frame's buffer commit. Nil restores the whole surface; a non-nil empty slice makes it click-through. `Node.Rect` places a border-box rectangle relative to a Stack's content box without parsing geometry CSS. It applies only to Stack children; debug builds diagnose other parents. | 9 | `Frame.SetInputRects` stages the clickable region with the frame's buffer commit. Nil restores the whole surface; a non-nil empty slice makes it click-through. `Node.Rect` places a border-box rectangle relative to a Stack's content box without parsing geometry CSS. It applies only to Stack children; debug builds diagnose other parents. | ||
M internal/harness/config.go +18 -2
| @@ -10,7 +10,9 @@ import ( | |||
| 10 | 10 | "strings" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | var sizePattern = regexp.MustCompile(`^[1-9][0-9]*x[1-9][0-9]*$`) | |
| 13 | + | // sizePattern accepts one headless output size, or several comma-separated | |
| 14 | + | // ones (HEADLESS-1, HEADLESS-2, ... left to right). | |
| 15 | + | var sizePattern = regexp.MustCompile(`^[1-9][0-9]*x[1-9][0-9]*(,[1-9][0-9]*x[1-9][0-9]*)*$`) | |
| 14 | 16 | var layoutPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) | |
| 15 | 17 | var socketPattern = regexp.MustCompile(`^wayland-[0-9]+$`) | |
| 16 | 18 | ||
| @@ -26,7 +28,21 @@ func Config(size, scale, layout, background string) (string, error) { | |||
| 26 | 28 | } | |
| 27 | 29 | // Wayland debug logs record each client cursor shape change, which | |
| 28 | 30 | // systemtests read from the compositor run log. | |
| 29 | - | return fmt.Sprintf("output.HEADLESS-1 = %s\noutput.HEADLESS-1.scale = %s\nkeyboard.layout = %s\nbackground = %s\nlog.debug = wayland\n", size, scale, layout, background), nil | |
| 31 | + | var b strings.Builder | |
| 32 | + | for i, s := range strings.Split(size, ",") { | |
| 33 | + | fmt.Fprintf(&b, "output.HEADLESS-%d = %s\noutput.HEADLESS-%d.scale = %s\n", i+1, s, i+1, scale) | |
| 34 | + | } | |
| 35 | + | fmt.Fprintf(&b, "keyboard.layout = %s\nbackground = %s\nlog.debug = wayland\n", layout, background) | |
| 36 | + | return b.String(), nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // FramesDir is where NeferWL writes the screenshots of the first output: the | |
| 40 | + | // frames directory itself, or its HEADLESS-1 subdirectory with several outputs. | |
| 41 | + | func FramesDir(frames, size string) string { | |
| 42 | + | if strings.Contains(size, ",") { | |
| 43 | + | return filepath.Join(frames, "HEADLESS-1") | |
| 44 | + | } | |
| 45 | + | return frames | |
| 30 | 46 | } | |
| 31 | 47 | ||
| 32 | 48 | // DiscoverSocket only accepts a real Unix socket paired with NeferWL's state file. | |
| @@ -10,7 +10,9 @@ import ( | |||||
| 10 | "strings" | 10 | "strings" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | var sizePattern = regexp.MustCompile(`^[1-9][0-9]*x[1-9][0-9]*$`) | 13 | + | // sizePattern accepts one headless output size, or several comma-separated |
| 14 | + | // ones (HEADLESS-1, HEADLESS-2, ... left to right). | |||
| 15 | + | var sizePattern = regexp.MustCompile(`^[1-9][0-9]*x[1-9][0-9]*(,[1-9][0-9]*x[1-9][0-9]*)*$`) | |||
| 14 | var layoutPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) | 16 | var layoutPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) | ||
| 15 | var socketPattern = regexp.MustCompile(`^wayland-[0-9]+$`) | 17 | var socketPattern = regexp.MustCompile(`^wayland-[0-9]+$`) | ||
| 16 | 18 | ||||
| @@ -26,7 +28,21 @@ func Config(size, scale, layout, background string) (string, error) { | |||||
| 26 | } | 28 | } | ||
| 27 | // Wayland debug logs record each client cursor shape change, which | 29 | // Wayland debug logs record each client cursor shape change, which | ||
| 28 | // systemtests read from the compositor run log. | 30 | // systemtests read from the compositor run log. | ||
| 29 | - | return fmt.Sprintf("output.HEADLESS-1 = %s\noutput.HEADLESS-1.scale = %s\nkeyboard.layout = %s\nbackground = %s\nlog.debug = wayland\n", size, scale, layout, background), nil | 31 | + | var b strings.Builder |
| 32 | + | for i, s := range strings.Split(size, ",") { | |||
| 33 | + | fmt.Fprintf(&b, "output.HEADLESS-%d = %s\noutput.HEADLESS-%d.scale = %s\n", i+1, s, i+1, scale) | |||
| 34 | + | } | |||
| 35 | + | fmt.Fprintf(&b, "keyboard.layout = %s\nbackground = %s\nlog.debug = wayland\n", layout, background) | |||
| 36 | + | return b.String(), nil | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // FramesDir is where NeferWL writes the screenshots of the first output: the | |||
| 40 | + | // frames directory itself, or its HEADLESS-1 subdirectory with several outputs. | |||
| 41 | + | func FramesDir(frames, size string) string { | |||
| 42 | + | if strings.Contains(size, ",") { | |||
| 43 | + | return filepath.Join(frames, "HEADLESS-1") | |||
| 44 | + | } | |||
| 45 | + | return frames | |||
| 30 | } | 46 | } | ||
| 31 | 47 | ||||
| 32 | // DiscoverSocket only accepts a real Unix socket paired with NeferWL's state file. | 48 | // DiscoverSocket only accepts a real Unix socket paired with NeferWL's state file. | ||
M internal/harness/harness_test.go +8 -1
| @@ -97,7 +97,14 @@ func TestConfig(t *testing.T) { | |||
| 97 | 97 | t.Fatalf("missing %s", s) | |
| 98 | 98 | } | |
| 99 | 99 | } | |
| 100 | - | for _, tc := range [][4]string{{"0x600", "1", "fr", "#336699"}, {"800x600", "5", "fr", "#336699"}, {"800x600", "1", "fr\nstartup = bad", "#336699"}, {"800x600", "1", "fr", "red"}} { | |
| 100 | + | multi, err := Config("800x600,320x200", "1", "us", "#000000") | |
| 101 | + | if err != nil || !strings.Contains(multi, "output.HEADLESS-1 = 800x600\n") || !strings.Contains(multi, "output.HEADLESS-2 = 320x200\noutput.HEADLESS-2.scale = 1\n") { | |
| 102 | + | t.Fatalf("multi-output config: %v\n%s", err, multi) | |
| 103 | + | } | |
| 104 | + | if FramesDir("f", "800x600") != "f" || FramesDir("f", "800x600,320x200") != filepath.Join("f", "HEADLESS-1") { | |
| 105 | + | t.Fatal("frames dir") | |
| 106 | + | } | |
| 107 | + | for _, tc := range [][4]string{{"0x600", "1", "fr", "#336699"}, {"800x600,", "1", "fr", "#336699"}, {"800x600", "5", "fr", "#336699"}, {"800x600", "1", "fr\nstartup = bad", "#336699"}, {"800x600", "1", "fr", "red"}} { | |
| 101 | 108 | if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil { | |
| 102 | 109 | t.Fatalf("accepted %v", tc) | |
| 103 | 110 | } | |
| @@ -97,7 +97,14 @@ func TestConfig(t *testing.T) { | |||||
| 97 | t.Fatalf("missing %s", s) | 97 | t.Fatalf("missing %s", s) | ||
| 98 | } | 98 | } | ||
| 99 | } | 99 | } | ||
| 100 | - | for _, tc := range [][4]string{{"0x600", "1", "fr", "#336699"}, {"800x600", "5", "fr", "#336699"}, {"800x600", "1", "fr\nstartup = bad", "#336699"}, {"800x600", "1", "fr", "red"}} { | 100 | + | multi, err := Config("800x600,320x200", "1", "us", "#000000") |
| 101 | + | if err != nil || !strings.Contains(multi, "output.HEADLESS-1 = 800x600\n") || !strings.Contains(multi, "output.HEADLESS-2 = 320x200\noutput.HEADLESS-2.scale = 1\n") { | |||
| 102 | + | t.Fatalf("multi-output config: %v\n%s", err, multi) | |||
| 103 | + | } | |||
| 104 | + | if FramesDir("f", "800x600") != "f" || FramesDir("f", "800x600,320x200") != filepath.Join("f", "HEADLESS-1") { | |||
| 105 | + | t.Fatal("frames dir") | |||
| 106 | + | } | |||
| 107 | + | for _, tc := range [][4]string{{"0x600", "1", "fr", "#336699"}, {"800x600,", "1", "fr", "#336699"}, {"800x600", "5", "fr", "#336699"}, {"800x600", "1", "fr\nstartup = bad", "#336699"}, {"800x600", "1", "fr", "red"}} { | |||
| 101 | if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil { | 108 | if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil { | ||
| 102 | t.Fatalf("accepted %v", tc) | 109 | t.Fatalf("accepted %v", tc) | ||
| 103 | } | 110 | } | ||
M internal/harness/run.go +4 -3
| @@ -169,7 +169,8 @@ func Run(ctx context.Context, o Options) (res Result, code int) { | |||
| 169 | 169 | } | |
| 170 | 170 | res.Artifacts[key] = path | |
| 171 | 171 | } | |
| 172 | - | res.Artifacts["frame"] = filepath.Join(out, "frames", "latest.png") | |
| 172 | + | shots := FramesDir(filepath.Join(out, "frames"), o.Size) // first output's screenshots | |
| 173 | + | res.Artifacts["frame"] = filepath.Join(shots, "latest.png") | |
| 173 | 174 | res.Artifacts["compositor_log"] = filepath.Join(out, "logs", "compositor.log") | |
| 174 | 175 | res.Artifacts["client_stdout"] = filepath.Join(out, "logs", "client.stdout") | |
| 175 | 176 | res.Artifacts["client_stderr"] = filepath.Join(out, "logs", "client.stderr") | |
| @@ -330,7 +331,7 @@ func Run(ctx context.Context, o Options) (res Result, code int) { | |||
| 330 | 331 | } | |
| 331 | 332 | } | |
| 332 | 333 | if requestSeen { | |
| 333 | - | err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath) | |
| 334 | + | err = copyNextFrame(shots, requestedAt, capturePath) | |
| 334 | 335 | if err == nil { | |
| 335 | 336 | res.Artifacts["capture"] = capturePath | |
| 336 | 337 | requestSeen = false | |
| @@ -366,7 +367,7 @@ func Run(ctx context.Context, o Options) (res Result, code int) { | |||
| 366 | 367 | } | |
| 367 | 368 | if requestSeen && err == nil { | |
| 368 | 369 | for runCtx.Err() == nil { | |
| 369 | - | err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath) | |
| 370 | + | err = copyNextFrame(shots, requestedAt, capturePath) | |
| 370 | 371 | if err == nil { | |
| 371 | 372 | res.Artifacts["capture"] = capturePath | |
| 372 | 373 | break | |
| @@ -169,7 +169,8 @@ func Run(ctx context.Context, o Options) (res Result, code int) { | |||||
| 169 | } | 169 | } | ||
| 170 | res.Artifacts[key] = path | 170 | res.Artifacts[key] = path | ||
| 171 | } | 171 | } | ||
| 172 | - | res.Artifacts["frame"] = filepath.Join(out, "frames", "latest.png") | 172 | + | shots := FramesDir(filepath.Join(out, "frames"), o.Size) // first output's screenshots |
| 173 | + | res.Artifacts["frame"] = filepath.Join(shots, "latest.png") | |||
| 173 | res.Artifacts["compositor_log"] = filepath.Join(out, "logs", "compositor.log") | 174 | res.Artifacts["compositor_log"] = filepath.Join(out, "logs", "compositor.log") | ||
| 174 | res.Artifacts["client_stdout"] = filepath.Join(out, "logs", "client.stdout") | 175 | res.Artifacts["client_stdout"] = filepath.Join(out, "logs", "client.stdout") | ||
| 175 | res.Artifacts["client_stderr"] = filepath.Join(out, "logs", "client.stderr") | 176 | res.Artifacts["client_stderr"] = filepath.Join(out, "logs", "client.stderr") | ||
| @@ -330,7 +331,7 @@ func Run(ctx context.Context, o Options) (res Result, code int) { | |||||
| 330 | } | 331 | } | ||
| 331 | } | 332 | } | ||
| 332 | if requestSeen { | 333 | if requestSeen { | ||
| 333 | - | err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath) | 334 | + | err = copyNextFrame(shots, requestedAt, capturePath) |
| 334 | if err == nil { | 335 | if err == nil { | ||
| 335 | res.Artifacts["capture"] = capturePath | 336 | res.Artifacts["capture"] = capturePath | ||
| 336 | requestSeen = false | 337 | requestSeen = false | ||
| @@ -366,7 +367,7 @@ func Run(ctx context.Context, o Options) (res Result, code int) { | |||||
| 366 | } | 367 | } | ||
| 367 | if requestSeen && err == nil { | 368 | if requestSeen && err == nil { | ||
| 368 | for runCtx.Err() == nil { | 369 | for runCtx.Err() == nil { | ||
| 369 | - | err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath) | 370 | + | err = copyNextFrame(shots, requestedAt, capturePath) |
| 370 | if err == nil { | 371 | if err == nil { | ||
| 371 | res.Artifacts["capture"] = capturePath | 372 | res.Artifacts["capture"] = capturePath | ||
| 372 | break | 373 | break | ||
M internal/platform/wayland/layer.go +16 -1
| @@ -62,6 +62,10 @@ type LayerOptions struct { | |||
| 62 | 62 | // compositor choose its preferred output. A named output that is absent | |
| 63 | 63 | // fails the connection; a named output removed later closes the window. | |
| 64 | 64 | Output string | |
| 65 | + | // OutputGlobal, when non-zero, selects an output lifetime returned by | |
| 66 | + | // Connection.Outputs instead of Output. The connection owner handles its | |
| 67 | + | // removal (OutputRemoved); the window is not closed for it. | |
| 68 | + | OutputGlobal uint32 | |
| 65 | 69 | // Namespace is the layer surface namespace; empty selects DefaultLayerNamespace. | |
| 66 | 70 | Namespace string | |
| 67 | 71 | // Anchor is a bit set of Anchor* values. | |
| @@ -119,6 +123,8 @@ func (o LayerOptions) Validate() error { | |||
| 119 | 123 | return errors.New("layer namespace contains NUL") | |
| 120 | 124 | case strings.ContainsRune(o.Output, 0): | |
| 121 | 125 | return errors.New("layer output contains NUL") | |
| 126 | + | case o.OutputGlobal != 0 && o.Output != "": | |
| 127 | + | return errors.New("layer output name and global are exclusive") | |
| 122 | 128 | } | |
| 123 | 129 | return nil | |
| 124 | 130 | } | |
| @@ -317,7 +323,16 @@ func (w *Window) createLayerSurface(o LayerOptions) error { | |||
| 317 | 323 | return &CapabilityError{Name: layershell.LayerShellInterface, Cause: fmt.Errorf("on-demand keyboard requires version 4, compositor negotiated %d", w.LayerShell.Version())} | |
| 318 | 324 | } | |
| 319 | 325 | var out *core.Output | |
| 320 | - | if o.Output != "" { | |
| 326 | + | if o.OutputGlobal != 0 { | |
| 327 | + | if w.connection == nil { | |
| 328 | + | return errors.New("layer output global requires a connection") | |
| 329 | + | } | |
| 330 | + | sel, ok := w.connection.outputs[o.OutputGlobal] | |
| 331 | + | if !ok || sel.proxy == nil { | |
| 332 | + | return fmt.Errorf("layer output %d unavailable", o.OutputGlobal) | |
| 333 | + | } | |
| 334 | + | out = sel.proxy | |
| 335 | + | } else if o.Output != "" { | |
| 321 | 336 | var global uint32 | |
| 322 | 337 | var err error | |
| 323 | 338 | if out, global, err = w.selectOutput(o.Output); err != nil { | |
| @@ -62,6 +62,10 @@ type LayerOptions struct { | |||||
| 62 | // compositor choose its preferred output. A named output that is absent | 62 | // compositor choose its preferred output. A named output that is absent | ||
| 63 | // fails the connection; a named output removed later closes the window. | 63 | // fails the connection; a named output removed later closes the window. | ||
| 64 | Output string | 64 | Output string | ||
| 65 | + | // OutputGlobal, when non-zero, selects an output lifetime returned by | |||
| 66 | + | // Connection.Outputs instead of Output. The connection owner handles its | |||
| 67 | + | // removal (OutputRemoved); the window is not closed for it. | |||
| 68 | + | OutputGlobal uint32 | |||
| 65 | // Namespace is the layer surface namespace; empty selects DefaultLayerNamespace. | 69 | // Namespace is the layer surface namespace; empty selects DefaultLayerNamespace. | ||
| 66 | Namespace string | 70 | Namespace string | ||
| 67 | // Anchor is a bit set of Anchor* values. | 71 | // Anchor is a bit set of Anchor* values. | ||
| @@ -119,6 +123,8 @@ func (o LayerOptions) Validate() error { | |||||
| 119 | return errors.New("layer namespace contains NUL") | 123 | return errors.New("layer namespace contains NUL") | ||
| 120 | case strings.ContainsRune(o.Output, 0): | 124 | case strings.ContainsRune(o.Output, 0): | ||
| 121 | return errors.New("layer output contains NUL") | 125 | return errors.New("layer output contains NUL") | ||
| 126 | + | case o.OutputGlobal != 0 && o.Output != "": | |||
| 127 | + | return errors.New("layer output name and global are exclusive") | |||
| 122 | } | 128 | } | ||
| 123 | return nil | 129 | return nil | ||
| 124 | } | 130 | } | ||
| @@ -317,7 +323,16 @@ func (w *Window) createLayerSurface(o LayerOptions) error { | |||||
| 317 | return &CapabilityError{Name: layershell.LayerShellInterface, Cause: fmt.Errorf("on-demand keyboard requires version 4, compositor negotiated %d", w.LayerShell.Version())} | 323 | return &CapabilityError{Name: layershell.LayerShellInterface, Cause: fmt.Errorf("on-demand keyboard requires version 4, compositor negotiated %d", w.LayerShell.Version())} | ||
| 318 | } | 324 | } | ||
| 319 | var out *core.Output | 325 | var out *core.Output | ||
| 320 | - | if o.Output != "" { | 326 | + | if o.OutputGlobal != 0 { |
| 327 | + | if w.connection == nil { | |||
| 328 | + | return errors.New("layer output global requires a connection") | |||
| 329 | + | } | |||
| 330 | + | sel, ok := w.connection.outputs[o.OutputGlobal] | |||
| 331 | + | if !ok || sel.proxy == nil { | |||
| 332 | + | return fmt.Errorf("layer output %d unavailable", o.OutputGlobal) | |||
| 333 | + | } | |||
| 334 | + | out = sel.proxy | |||
| 335 | + | } else if o.Output != "" { | |||
| 321 | var global uint32 | 336 | var global uint32 | ||
| 322 | var err error | 337 | var err error | ||
| 323 | if out, global, err = w.selectOutput(o.Output); err != nil { | 338 | if out, global, err = w.selectOutput(o.Output); err != nil { | ||
M internal/ui/headless_test.go +32 -1
| @@ -28,6 +28,11 @@ func requireCompositor(t *testing.T) { | |||
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) []string { | |
| 31 | + | t.Helper() | |
| 32 | + | return runHeadlessSize(t, "640x480", scenario, script, timeout) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func runHeadlessSize(t *testing.T, size, scenario, script string, timeout time.Duration) []string { | |
| 31 | 36 | t.Helper() | |
| 32 | 37 | requireCompositor(t) | |
| 33 | 38 | dir := t.TempDir() | |
| @@ -42,7 +47,7 @@ func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) [ | |||
| 42 | 47 | t.Setenv(headlessClientEnv, scenario) | |
| 43 | 48 | t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events) | |
| 44 | 49 | res, code := harness.Run(context.Background(), harness.Options{ | |
| 45 | - | Size: "640x480", Scale: "1", Layout: "us", Background: "#000000", | |
| 50 | + | Size: size, Scale: "1", Layout: "us", Background: "#000000", | |
| 46 | 51 | Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true, | |
| 47 | 52 | Timeout: timeout, ReadyTimeout: 5 * time.Second, | |
| 48 | 53 | Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"}, | |
| @@ -91,6 +96,25 @@ func TestHeadlessLayerHooks(t *testing.T) { | |||
| 91 | 96 | } | |
| 92 | 97 | } | |
| 93 | 98 | ||
| 99 | + | // Every output gets a surface laid out at its own size; the wake redraws them all. | |
| 100 | + | func TestHeadlessLayerAllOutputs(t *testing.T) { | |
| 101 | + | lines := runHeadlessSize(t, "640x480,320x200", "all-outputs", "", 30*time.Second) | |
| 102 | + | joined := "\n" + strings.Join(lines, "\n") + "\n" | |
| 103 | + | w := indexOf(lines, "wake-sent") | |
| 104 | + | if w < 0 { | |
| 105 | + | t.Fatalf("no wake:\n%s", joined) | |
| 106 | + | } | |
| 107 | + | after := "\n" + strings.Join(lines[w:], "\n") + "\n" | |
| 108 | + | for _, want := range []string{"\nview size 640x480\n", "\nview size 320x200\n"} { | |
| 109 | + | if !strings.Contains(after, want) { | |
| 110 | + | t.Errorf("missing %q after the wake in:\n%s", strings.TrimSpace(want), joined) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if !strings.Contains(joined, "\nexit context deadline exceeded\n") { | |
| 114 | + | t.Errorf("Run must end only with its context:\n%s", joined) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 94 | 118 | func TestHeadlessSurfaceHookErrorAborts(t *testing.T) { | |
| 95 | 119 | lines := runHeadless(t, "hook-error", "", 20*time.Second) | |
| 96 | 120 | joined := strings.Join(lines, "\n") | |
| @@ -160,6 +184,13 @@ func TestHeadlessClientProcess(t *testing.T) { | |||
| 160 | 184 | return false | |
| 161 | 185 | }), | |
| 162 | 186 | ) | |
| 187 | + | case "all-outputs": | |
| 188 | + | ctx, cancel = context.WithTimeout(context.Background(), 4*time.Second) | |
| 189 | + | defer cancel() | |
| 190 | + | wake := make(chan struct{}, 1) | |
| 191 | + | go func() { time.Sleep(2 * time.Second); logf("wake-sent"); wake <- struct{}{} }() | |
| 192 | + | options = append(options, Wake(wake), Layer(LayerConfig{AllOutputs: true, Level: LayerOverlay, | |
| 193 | + | Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, ExclusiveZone: -1, InputRects: []Rect{}})) | |
| 163 | 194 | case "hook-error": | |
| 164 | 195 | options = append(options, Layer(LayerConfig{Level: LayerOverlay}), | |
| 165 | 196 | OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") })) | |
| @@ -28,6 +28,11 @@ func requireCompositor(t *testing.T) { | |||||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) []string { | 30 | func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) []string { | ||
| 31 | + | t.Helper() | |||
| 32 | + | return runHeadlessSize(t, "640x480", scenario, script, timeout) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func runHeadlessSize(t *testing.T, size, scenario, script string, timeout time.Duration) []string { | |||
| 31 | t.Helper() | 36 | t.Helper() | ||
| 32 | requireCompositor(t) | 37 | requireCompositor(t) | ||
| 33 | dir := t.TempDir() | 38 | dir := t.TempDir() | ||
| @@ -42,7 +47,7 @@ func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) [ | |||||
| 42 | t.Setenv(headlessClientEnv, scenario) | 47 | t.Setenv(headlessClientEnv, scenario) | ||
| 43 | t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events) | 48 | t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events) | ||
| 44 | res, code := harness.Run(context.Background(), harness.Options{ | 49 | res, code := harness.Run(context.Background(), harness.Options{ | ||
| 45 | - | Size: "640x480", Scale: "1", Layout: "us", Background: "#000000", | 50 | + | Size: size, Scale: "1", Layout: "us", Background: "#000000", |
| 46 | Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true, | 51 | Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true, | ||
| 47 | Timeout: timeout, ReadyTimeout: 5 * time.Second, | 52 | Timeout: timeout, ReadyTimeout: 5 * time.Second, | ||
| 48 | Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"}, | 53 | Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"}, | ||
| @@ -91,6 +96,25 @@ func TestHeadlessLayerHooks(t *testing.T) { | |||||
| 91 | } | 96 | } | ||
| 92 | } | 97 | } | ||
| 93 | 98 | ||||
| 99 | + | // Every output gets a surface laid out at its own size; the wake redraws them all. | |||
| 100 | + | func TestHeadlessLayerAllOutputs(t *testing.T) { | |||
| 101 | + | lines := runHeadlessSize(t, "640x480,320x200", "all-outputs", "", 30*time.Second) | |||
| 102 | + | joined := "\n" + strings.Join(lines, "\n") + "\n" | |||
| 103 | + | w := indexOf(lines, "wake-sent") | |||
| 104 | + | if w < 0 { | |||
| 105 | + | t.Fatalf("no wake:\n%s", joined) | |||
| 106 | + | } | |||
| 107 | + | after := "\n" + strings.Join(lines[w:], "\n") + "\n" | |||
| 108 | + | for _, want := range []string{"\nview size 640x480\n", "\nview size 320x200\n"} { | |||
| 109 | + | if !strings.Contains(after, want) { | |||
| 110 | + | t.Errorf("missing %q after the wake in:\n%s", strings.TrimSpace(want), joined) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | if !strings.Contains(joined, "\nexit context deadline exceeded\n") { | |||
| 114 | + | t.Errorf("Run must end only with its context:\n%s", joined) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 94 | func TestHeadlessSurfaceHookErrorAborts(t *testing.T) { | 118 | func TestHeadlessSurfaceHookErrorAborts(t *testing.T) { | ||
| 95 | lines := runHeadless(t, "hook-error", "", 20*time.Second) | 119 | lines := runHeadless(t, "hook-error", "", 20*time.Second) | ||
| 96 | joined := strings.Join(lines, "\n") | 120 | joined := strings.Join(lines, "\n") | ||
| @@ -160,6 +184,13 @@ func TestHeadlessClientProcess(t *testing.T) { | |||||
| 160 | return false | 184 | return false | ||
| 161 | }), | 185 | }), | ||
| 162 | ) | 186 | ) | ||
| 187 | + | case "all-outputs": | |||
| 188 | + | ctx, cancel = context.WithTimeout(context.Background(), 4*time.Second) | |||
| 189 | + | defer cancel() | |||
| 190 | + | wake := make(chan struct{}, 1) | |||
| 191 | + | go func() { time.Sleep(2 * time.Second); logf("wake-sent"); wake <- struct{}{} }() | |||
| 192 | + | options = append(options, Wake(wake), Layer(LayerConfig{AllOutputs: true, Level: LayerOverlay, | |||
| 193 | + | Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, ExclusiveZone: -1, InputRects: []Rect{}})) | |||
| 163 | case "hook-error": | 194 | case "hook-error": | ||
| 164 | options = append(options, Layer(LayerConfig{Level: LayerOverlay}), | 195 | options = append(options, Layer(LayerConfig{Level: LayerOverlay}), | ||
| 165 | OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") })) | 196 | OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") })) | ||
M internal/ui/hooks_test.go +19 -0
| @@ -58,6 +58,8 @@ func TestLayerConfigRejectsInvalid(t *testing.T) { | |||
| 58 | 58 | "bad keyboard": {Level: LayerTop, Keyboard: 7}, | |
| 59 | 59 | "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}}, | |
| 60 | 60 | "nul output": {Level: LayerTop, Output: "a\x00"}, | |
| 61 | + | "all+output": {Level: LayerTop, AllOutputs: true, Output: "DP-1"}, | |
| 62 | + | "all+keyboard": {Level: LayerTop, AllOutputs: true, Keyboard: KeyboardOnDemand}, | |
| 61 | 63 | } { | |
| 62 | 64 | if _, err := c.surfaceOptions(); err == nil { | |
| 63 | 65 | t.Errorf("%s: expected error", name) | |
| @@ -185,6 +187,23 @@ func TestExternalWakeForcesBuild(t *testing.T) { | |||
| 185 | 187 | } | |
| 186 | 188 | } | |
| 187 | 189 | ||
| 190 | + | // AllOutputs redraws the other surfaces only after a build that handled events. | |
| 191 | + | func TestBuildReportsConsumedInput(t *testing.T) { | |
| 192 | + | r := newRuntime() | |
| 193 | + | view := func(f *Frame) { f.Root().Button("Go", Key("go")) } | |
| 194 | + | r.Build(view) | |
| 195 | + | if r.consumedInput() { | |
| 196 | + | t.Fatal("first build consumed nothing") | |
| 197 | + | } | |
| 198 | + | r.Queue(inputEvent{Target: r.Target(0), Kind: "activate"}) | |
| 199 | + | if !r.Build(view) || !r.consumedInput() { | |
| 200 | + | t.Fatal("event build not reported") | |
| 201 | + | } | |
| 202 | + | if !r.Build(view) || r.consumedInput() { | |
| 203 | + | t.Fatal("follow-up build must not report events") | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 188 | 207 | func TestNodeRectAndFrameSize(t *testing.T) { | |
| 189 | 208 | r := newRuntime() | |
| 190 | 209 | r.width, r.height = 400, 300 | |
| @@ -58,6 +58,8 @@ func TestLayerConfigRejectsInvalid(t *testing.T) { | |||||
| 58 | "bad keyboard": {Level: LayerTop, Keyboard: 7}, | 58 | "bad keyboard": {Level: LayerTop, Keyboard: 7}, | ||
| 59 | "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}}, | 59 | "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}}, | ||
| 60 | "nul output": {Level: LayerTop, Output: "a\x00"}, | 60 | "nul output": {Level: LayerTop, Output: "a\x00"}, | ||
| 61 | + | "all+output": {Level: LayerTop, AllOutputs: true, Output: "DP-1"}, | |||
| 62 | + | "all+keyboard": {Level: LayerTop, AllOutputs: true, Keyboard: KeyboardOnDemand}, | |||
| 61 | } { | 63 | } { | ||
| 62 | if _, err := c.surfaceOptions(); err == nil { | 64 | if _, err := c.surfaceOptions(); err == nil { | ||
| 63 | t.Errorf("%s: expected error", name) | 65 | t.Errorf("%s: expected error", name) | ||
| @@ -185,6 +187,23 @@ func TestExternalWakeForcesBuild(t *testing.T) { | |||||
| 185 | } | 187 | } | ||
| 186 | } | 188 | } | ||
| 187 | 189 | ||||
| 190 | + | // AllOutputs redraws the other surfaces only after a build that handled events. | |||
| 191 | + | func TestBuildReportsConsumedInput(t *testing.T) { | |||
| 192 | + | r := newRuntime() | |||
| 193 | + | view := func(f *Frame) { f.Root().Button("Go", Key("go")) } | |||
| 194 | + | r.Build(view) | |||
| 195 | + | if r.consumedInput() { | |||
| 196 | + | t.Fatal("first build consumed nothing") | |||
| 197 | + | } | |||
| 198 | + | r.Queue(inputEvent{Target: r.Target(0), Kind: "activate"}) | |||
| 199 | + | if !r.Build(view) || !r.consumedInput() { | |||
| 200 | + | t.Fatal("event build not reported") | |||
| 201 | + | } | |||
| 202 | + | if !r.Build(view) || r.consumedInput() { | |||
| 203 | + | t.Fatal("follow-up build must not report events") | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 188 | func TestNodeRectAndFrameSize(t *testing.T) { | 207 | func TestNodeRectAndFrameSize(t *testing.T) { | ||
| 189 | r := newRuntime() | 208 | r := newRuntime() | ||
| 190 | r.width, r.height = 400, 300 | 209 | r.width, r.height = 400, 300 | ||
M internal/ui/layer.go +10 -1
| @@ -52,7 +52,13 @@ type Rect struct{ X, Y, Width, Height int32 } | |||
| 52 | 52 | // edges); 0 reserves nothing and asks to avoid other surfaces' positive zones; | |
| 53 | 53 | // -1 ignores other exclusive zones and extends to the anchored output edges. | |
| 54 | 54 | type LayerConfig struct { | |
| 55 | - | Output string // output name; empty lets the compositor choose | |
| 55 | + | Output string // output name; empty lets the compositor choose | |
| 56 | + | // AllOutputs puts one surface on every output, including outputs added | |
| 57 | + | // while Run is running; removed outputs lose theirs. Every surface shows | |
| 58 | + | // the same view and model, laid out at its own size and scale. Run then | |
| 59 | + | // returns only on cancellation or failure. It excludes Output, keyboard | |
| 60 | + | // interactivity, OnSurface, OnResize and RunFrames. | |
| 61 | + | AllOutputs bool | |
| 56 | 62 | Namespace string // defaults to "nefergui" | |
| 57 | 63 | Level LayerLevel | |
| 58 | 64 | Anchors Anchor | |
| @@ -67,6 +73,9 @@ type LayerConfig struct { | |||
| 67 | 73 | ||
| 68 | 74 | // surfaceOptions lowers the config to platform options and validates it. | |
| 69 | 75 | func (c LayerConfig) surfaceOptions() (wayland.SurfaceOptions, error) { | |
| 76 | + | if c.AllOutputs && (c.Output != "" || c.Keyboard != KeyboardNone) { | |
| 77 | + | return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: AllOutputs excludes Output and keyboard interactivity") | |
| 78 | + | } | |
| 70 | 79 | l := wayland.LayerOptions{Output: c.Output, Namespace: c.Namespace, ExclusiveZone: c.ExclusiveZone, Margin: c.Margin} | |
| 71 | 80 | switch c.Level { | |
| 72 | 81 | case LayerBackground: | |
| @@ -52,7 +52,13 @@ type Rect struct{ X, Y, Width, Height int32 } | |||||
| 52 | // edges); 0 reserves nothing and asks to avoid other surfaces' positive zones; | 52 | // edges); 0 reserves nothing and asks to avoid other surfaces' positive zones; | ||
| 53 | // -1 ignores other exclusive zones and extends to the anchored output edges. | 53 | // -1 ignores other exclusive zones and extends to the anchored output edges. | ||
| 54 | type LayerConfig struct { | 54 | type LayerConfig struct { | ||
| 55 | - | Output string // output name; empty lets the compositor choose | 55 | + | Output string // output name; empty lets the compositor choose |
| 56 | + | // AllOutputs puts one surface on every output, including outputs added | |||
| 57 | + | // while Run is running; removed outputs lose theirs. Every surface shows | |||
| 58 | + | // the same view and model, laid out at its own size and scale. Run then | |||
| 59 | + | // returns only on cancellation or failure. It excludes Output, keyboard | |||
| 60 | + | // interactivity, OnSurface, OnResize and RunFrames. | |||
| 61 | + | AllOutputs bool | |||
| 56 | Namespace string // defaults to "nefergui" | 62 | Namespace string // defaults to "nefergui" | ||
| 57 | Level LayerLevel | 63 | Level LayerLevel | ||
| 58 | Anchors Anchor | 64 | Anchors Anchor | ||
| @@ -67,6 +73,9 @@ type LayerConfig struct { | |||||
| 67 | 73 | ||||
| 68 | // surfaceOptions lowers the config to platform options and validates it. | 74 | // surfaceOptions lowers the config to platform options and validates it. | ||
| 69 | func (c LayerConfig) surfaceOptions() (wayland.SurfaceOptions, error) { | 75 | func (c LayerConfig) surfaceOptions() (wayland.SurfaceOptions, error) { | ||
| 76 | + | if c.AllOutputs && (c.Output != "" || c.Keyboard != KeyboardNone) { | |||
| 77 | + | return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: AllOutputs excludes Output and keyboard interactivity") | |||
| 78 | + | } | |||
| 70 | l := wayland.LayerOptions{Output: c.Output, Namespace: c.Namespace, ExclusiveZone: c.ExclusiveZone, Margin: c.Margin} | 79 | l := wayland.LayerOptions{Output: c.Output, Namespace: c.Namespace, ExclusiveZone: c.ExclusiveZone, Margin: c.Margin} | ||
| 71 | switch c.Level { | 80 | switch c.Level { | ||
| 72 | case LayerBackground: | 81 | case LayerBackground: | ||
A internal/ui/outputs.go +365 -0
| @@ -0,0 +1,365 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "slices" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefergui/internal/css" | |
| 13 | + | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 14 | + | "github.com/bnema/nefergui/internal/presentation/session" | |
| 15 | + | "github.com/bnema/nefergui/internal/render" | |
| 16 | + | "github.com/bnema/nefergui/internal/text" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // outputSurface is one layer surface and its own layout runtime: outputs | |
| 20 | + | // differ in size and scale, so each lays the shared view out separately. | |
| 21 | + | type outputSurface struct { | |
| 22 | + | w *wayland.Window | |
| 23 | + | output uint32 | |
| 24 | + | r *runtime | |
| 25 | + | s *session.Session | |
| 26 | + | p *session.Pump | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // outputSet runs one layer surface per output on a single connection and | |
| 30 | + | // owner loop, following the session-lock structure. Outputs added later gain a | |
| 31 | + | // surface; removed outputs, or surfaces the compositor closes, lose theirs. | |
| 32 | + | type outputSet struct { | |
| 33 | + | ctx context.Context | |
| 34 | + | conn *wayland.Connection | |
| 35 | + | opts wayland.SurfaceOptions | |
| 36 | + | width int32 | |
| 37 | + | height int32 | |
| 38 | + | transparent bool | |
| 39 | + | sheet css.Sheet | |
| 40 | + | catalog *text.Catalog | |
| 41 | + | view func(*Frame) | |
| 42 | + | hooks *inputHooks | |
| 43 | + | surfaces map[*wayland.Window]*outputSurface | |
| 44 | + | pending map[*wayland.Window]uint32 // created, waiting for configure and feedback | |
| 45 | + | covered map[uint32]bool // outputs with a surface or a pending one | |
| 46 | + | gpuWake chan struct{} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // runAllOutputs is Run with LayerConfig.AllOutputs. It returns on cancellation | |
| 50 | + | // or the first failure; it never ends because outputs come and go. | |
| 51 | + | func runAllOutputs(ctx context.Context, cfg windowConfig, view func(*Frame)) (err error) { | |
| 52 | + | if cfg.onSurface != nil || cfg.onResize != nil { | |
| 53 | + | return errors.New("nefergui: AllOutputs excludes OnSurface and OnResize") | |
| 54 | + | } | |
| 55 | + | opts, err := cfg.layer.surfaceOptions() | |
| 56 | + | if err != nil { | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | a := &outputSet{ctx: ctx, opts: opts, width: int32(cfg.width), height: int32(cfg.height), transparent: cfg.transparent, view: view, | |
| 60 | + | hooks: &inputHooks{onInput: cfg.onInput}, surfaces: map[*wayland.Window]*outputSurface{}, pending: map[*wayland.Window]uint32{}, | |
| 61 | + | covered: map[uint32]bool{}, gpuWake: make(chan struct{}, 1)} | |
| 62 | + | if cfg.styles != "" { | |
| 63 | + | data, e := os.ReadFile(cfg.styles) | |
| 64 | + | if e != nil { | |
| 65 | + | return fmt.Errorf("nefergui: styles: %w", e) | |
| 66 | + | } | |
| 67 | + | a.sheet = css.Parse(string(data)) | |
| 68 | + | } | |
| 69 | + | if a.catalog, err = text.Load(text.SystemSource{}); err != nil { | |
| 70 | + | return fmt.Errorf("nefergui: fonts: %w", err) | |
| 71 | + | } | |
| 72 | + | if len(a.catalog.Faces) == 0 { | |
| 73 | + | return errors.New("nefergui: no usable fonts") | |
| 74 | + | } | |
| 75 | + | if a.conn, err = wayland.ConnectConnection(ctx, ""); err != nil { | |
| 76 | + | return fmt.Errorf("nefergui: open platform: %w", err) | |
| 77 | + | } | |
| 78 | + | defer func() { | |
| 79 | + | a.closeAll() | |
| 80 | + | err = errors.Join(err, a.conn.Close()) | |
| 81 | + | }() | |
| 82 | + | // Surfaces for the outputs present now are configured before the reader | |
| 83 | + | // starts; output events seen meanwhile are held and handled right after. | |
| 84 | + | if err = a.reconcile(); err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | events := a.conn.StartReader() | |
| 88 | + | for _, ev := range a.conn.TakeHeld() { | |
| 89 | + | if err = a.handle(ev); err != nil { | |
| 90 | + | return err | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ext := make(chan struct{}, 1) | |
| 94 | + | if cfg.wake != nil { | |
| 95 | + | loopCtx, stop := context.WithCancel(ctx) | |
| 96 | + | defer stop() | |
| 97 | + | go forward(loopCtx, cfg.wake, ext) | |
| 98 | + | } | |
| 99 | + | return a.loop(events, ext) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // forward relays external wake tokens to the owner loop until ctx ends or ch | |
| 103 | + | // closes; tokens coalesce. | |
| 104 | + | func forward(ctx context.Context, ch <-chan struct{}, out chan<- struct{}) { | |
| 105 | + | for { | |
| 106 | + | select { | |
| 107 | + | case <-ctx.Done(): | |
| 108 | + | return | |
| 109 | + | case _, ok := <-ch: | |
| 110 | + | if !ok { | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | select { | |
| 114 | + | case out <- struct{}{}: | |
| 115 | + | default: | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (a *outputSet) loop(events <-chan wayland.Event, ext <-chan struct{}) error { | |
| 122 | + | for { | |
| 123 | + | if err := a.promote(); err != nil { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | // Every Redraw runs on this goroutine, so draining each runtime's own | |
| 127 | + | // wake here sees every request, including Build follow-ups. | |
| 128 | + | a.drainWakes() | |
| 129 | + | retry := false | |
| 130 | + | for _, o := range a.surfaces { | |
| 131 | + | again, err := o.p.Step() | |
| 132 | + | if err != nil { | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | retry = retry || again | |
| 136 | + | } | |
| 137 | + | if a.drainWakes() { | |
| 138 | + | continue // a Build asked for a follow-up frame | |
| 139 | + | } | |
| 140 | + | var retryC <-chan time.Time | |
| 141 | + | if retry { | |
| 142 | + | retryC = time.After(5 * time.Millisecond) | |
| 143 | + | } | |
| 144 | + | select { | |
| 145 | + | case <-a.ctx.Done(): | |
| 146 | + | return a.ctx.Err() | |
| 147 | + | case <-ext: | |
| 148 | + | for _, o := range a.surfaces { | |
| 149 | + | o.r.Redraw() | |
| 150 | + | } | |
| 151 | + | case <-a.gpuWake: | |
| 152 | + | case <-retryC: | |
| 153 | + | case ev := <-events: | |
| 154 | + | if err := a.handle(ev); err != nil { | |
| 155 | + | return err | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // drainWakes invalidates every surface whose runtime requested a frame and | |
| 162 | + | // reports whether any did. | |
| 163 | + | func (a *outputSet) drainWakes() bool { | |
| 164 | + | woke := false | |
| 165 | + | for _, o := range a.surfaces { | |
| 166 | + | select { | |
| 167 | + | case <-o.r.wake: | |
| 168 | + | o.p.Invalidate() | |
| 169 | + | woke = true | |
| 170 | + | default: | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | return woke | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (a *outputSet) handle(ev wayland.Event) error { | |
| 177 | + | switch ev.Kind { | |
| 178 | + | case wayland.BufferRelease: | |
| 179 | + | if o := a.surfaces[ev.Window]; o != nil { | |
| 180 | + | return o.p.BufferReleased(ev.BufferID) | |
| 181 | + | } | |
| 182 | + | return nil | |
| 183 | + | case wayland.OutputRemoved: | |
| 184 | + | a.removeOutput(ev.Output.Global) | |
| 185 | + | } | |
| 186 | + | if err := a.conn.Apply(ev); err != nil { | |
| 187 | + | return err | |
| 188 | + | } | |
| 189 | + | if w := ev.Window; w != nil && w.Closed { | |
| 190 | + | // The compositor closed this surface (layer_surface.closed); the output | |
| 191 | + | // stays covered so it is not recreated in a loop. | |
| 192 | + | a.closeWindow(w) | |
| 193 | + | return nil | |
| 194 | + | } | |
| 195 | + | switch ev.Kind { | |
| 196 | + | case wayland.OutputGlobal, wayland.OutputNamed, wayland.OutputAdded: | |
| 197 | + | return a.reconcile() | |
| 198 | + | } | |
| 199 | + | // Seat changes queue input (leave, focus-out) on every window. | |
| 200 | + | for _, o := range a.surfaces { | |
| 201 | + | if err := a.input(o); err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // reconcile creates a layer surface for every named output without one. | |
| 209 | + | func (a *outputSet) reconcile() error { | |
| 210 | + | for _, out := range a.conn.Outputs() { | |
| 211 | + | if a.covered[out.Global] { | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | a.covered[out.Global] = true | |
| 215 | + | l := *a.opts.Layer | |
| 216 | + | l.OutputGlobal = out.Global | |
| 217 | + | opts := a.opts | |
| 218 | + | opts.Layer = &l | |
| 219 | + | w, err := a.conn.NewWindow(a.ctx, a.width, a.height, a.transparent, opts) | |
| 220 | + | if err != nil { | |
| 221 | + | return fmt.Errorf("nefergui: layer surface for output %s: %w", out.Name, err) | |
| 222 | + | } | |
| 223 | + | a.pending[w] = out.Global | |
| 224 | + | } | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // promote opens the render path of every configured window with complete | |
| 229 | + | // dmabuf feedback, in ascending output order. | |
| 230 | + | func (a *outputSet) promote() error { | |
| 231 | + | ready := make([]*wayland.Window, 0, len(a.pending)) | |
| 232 | + | for w := range a.pending { | |
| 233 | + | if w.Configured && w.FeedbackDone { | |
| 234 | + | ready = append(ready, w) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | slices.SortFunc(ready, func(x, y *wayland.Window) int { return cmp.Compare(a.pending[x], a.pending[y]) }) | |
| 238 | + | for _, w := range ready { | |
| 239 | + | output := a.pending[w] | |
| 240 | + | delete(a.pending, w) | |
| 241 | + | if w.Closed { // closed by the compositor before the reader started | |
| 242 | + | _ = w.Close() | |
| 243 | + | continue | |
| 244 | + | } | |
| 245 | + | o, err := a.open(w, output) // closes w on failure | |
| 246 | + | if err != nil { | |
| 247 | + | return fmt.Errorf("nefergui: layer render path: %w", err) | |
| 248 | + | } | |
| 249 | + | a.surfaces[w] = o | |
| 250 | + | } | |
| 251 | + | return nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (a *outputSet) open(w *wayland.Window, output uint32) (*outputSurface, error) { | |
| 255 | + | s, err := session.OpenWindow(w, a.transparent) | |
| 256 | + | if err != nil { | |
| 257 | + | return nil, err | |
| 258 | + | } | |
| 259 | + | r := newRuntime() | |
| 260 | + | r.styles = css.Compile(css.UA(), a.sheet) | |
| 261 | + | r.setTextEngine(text.NewEngine(a.catalog)) | |
| 262 | + | o := &outputSurface{w: w, output: output, r: r, s: s} | |
| 263 | + | if o.p, err = s.NewPump(a.ctx, a.gpuWake, func() (render.Frame, bool, error) { return a.draw(o) }); err != nil { | |
| 264 | + | _ = s.Close() | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | return o, nil | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func (a *outputSet) draw(o *outputSurface) (render.Frame, bool, error) { | |
| 271 | + | w := o.w | |
| 272 | + | o.r.route(platformInput{Kind: "resize", Width: float64(w.Width), Height: float64(w.Height), Scale: w.Scale}) | |
| 273 | + | if !o.r.Build(a.view) { | |
| 274 | + | return render.Frame{}, false, nil | |
| 275 | + | } | |
| 276 | + | if o.r.consumedInput() { | |
| 277 | + | // Control events may have changed the shared model: the other outputs | |
| 278 | + | // rebuild without events, so this cannot bounce back. | |
| 279 | + | for _, other := range a.surfaces { | |
| 280 | + | if other != o { | |
| 281 | + | other.r.Redraw() | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | if err := o.r.applyInputRects(w.StageInputRects); err != nil { | |
| 286 | + | return render.Frame{}, false, err | |
| 287 | + | } | |
| 288 | + | if err := w.SetCursor(cursorShape(o.r.cursor())); err != nil { | |
| 289 | + | return render.Frame{}, false, err | |
| 290 | + | } | |
| 291 | + | pw, ph, err := w.PhysicalSize() | |
| 292 | + | if err != nil { | |
| 293 | + | return render.Frame{}, false, err | |
| 294 | + | } | |
| 295 | + | frame, err := o.s.Preparer.Prepare(o.r.output.Display, w.Scale, int(pw), int(ph)) | |
| 296 | + | return frame, true, err | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // input routes pointer input to the surface's runtime. A raw hook that | |
| 300 | + | // changed the model redraws every output, since they share it. | |
| 301 | + | func (a *outputSet) input(o *outputSurface) error { | |
| 302 | + | inputs := o.w.DrainInput(nil) | |
| 303 | + | if len(inputs) == 0 { | |
| 304 | + | return nil | |
| 305 | + | } | |
| 306 | + | for _, in := range inputs { | |
| 307 | + | switch { | |
| 308 | + | case in.Enter: | |
| 309 | + | o.w.PointerEntered(in.Serial, in.PointerGen) | |
| 310 | + | case in.Kind == "leave": | |
| 311 | + | o.w.PointerLeft() | |
| 312 | + | } | |
| 313 | + | if a.hooks.input(in) { | |
| 314 | + | for _, other := range a.surfaces { | |
| 315 | + | other.r.Redraw() | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | o.r.routeInput(in) | |
| 319 | + | } | |
| 320 | + | o.p.Invalidate() | |
| 321 | + | return o.w.SetCursor(cursorShape(o.r.cursor())) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func (a *outputSet) removeOutput(global uint32) { | |
| 325 | + | delete(a.covered, global) | |
| 326 | + | for w, output := range a.pending { | |
| 327 | + | if output == global { | |
| 328 | + | delete(a.pending, w) | |
| 329 | + | _ = w.Close() | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | for w, o := range a.surfaces { | |
| 333 | + | if o.output == global { | |
| 334 | + | delete(a.surfaces, w) | |
| 335 | + | o.close() | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func (a *outputSet) closeWindow(w *wayland.Window) { | |
| 341 | + | if _, ok := a.pending[w]; ok { | |
| 342 | + | delete(a.pending, w) | |
| 343 | + | _ = w.Close() | |
| 344 | + | } | |
| 345 | + | if o := a.surfaces[w]; o != nil { | |
| 346 | + | delete(a.surfaces, w) | |
| 347 | + | o.close() | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func (o *outputSurface) close() { | |
| 352 | + | o.p.Close() | |
| 353 | + | _ = o.s.Close() | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func (a *outputSet) closeAll() { | |
| 357 | + | for w, o := range a.surfaces { | |
| 358 | + | delete(a.surfaces, w) | |
| 359 | + | o.close() | |
| 360 | + | } | |
| 361 | + | for w := range a.pending { | |
| 362 | + | delete(a.pending, w) | |
| 363 | + | _ = w.Close() | |
| 364 | + | } | |
| 365 | + | } | |
| @@ -0,0 +1,365 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "cmp" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "slices" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/nefergui/internal/css" | |||
| 13 | + | "github.com/bnema/nefergui/internal/platform/wayland" | |||
| 14 | + | "github.com/bnema/nefergui/internal/presentation/session" | |||
| 15 | + | "github.com/bnema/nefergui/internal/render" | |||
| 16 | + | "github.com/bnema/nefergui/internal/text" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // outputSurface is one layer surface and its own layout runtime: outputs | |||
| 20 | + | // differ in size and scale, so each lays the shared view out separately. | |||
| 21 | + | type outputSurface struct { | |||
| 22 | + | w *wayland.Window | |||
| 23 | + | output uint32 | |||
| 24 | + | r *runtime | |||
| 25 | + | s *session.Session | |||
| 26 | + | p *session.Pump | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // outputSet runs one layer surface per output on a single connection and | |||
| 30 | + | // owner loop, following the session-lock structure. Outputs added later gain a | |||
| 31 | + | // surface; removed outputs, or surfaces the compositor closes, lose theirs. | |||
| 32 | + | type outputSet struct { | |||
| 33 | + | ctx context.Context | |||
| 34 | + | conn *wayland.Connection | |||
| 35 | + | opts wayland.SurfaceOptions | |||
| 36 | + | width int32 | |||
| 37 | + | height int32 | |||
| 38 | + | transparent bool | |||
| 39 | + | sheet css.Sheet | |||
| 40 | + | catalog *text.Catalog | |||
| 41 | + | view func(*Frame) | |||
| 42 | + | hooks *inputHooks | |||
| 43 | + | surfaces map[*wayland.Window]*outputSurface | |||
| 44 | + | pending map[*wayland.Window]uint32 // created, waiting for configure and feedback | |||
| 45 | + | covered map[uint32]bool // outputs with a surface or a pending one | |||
| 46 | + | gpuWake chan struct{} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // runAllOutputs is Run with LayerConfig.AllOutputs. It returns on cancellation | |||
| 50 | + | // or the first failure; it never ends because outputs come and go. | |||
| 51 | + | func runAllOutputs(ctx context.Context, cfg windowConfig, view func(*Frame)) (err error) { | |||
| 52 | + | if cfg.onSurface != nil || cfg.onResize != nil { | |||
| 53 | + | return errors.New("nefergui: AllOutputs excludes OnSurface and OnResize") | |||
| 54 | + | } | |||
| 55 | + | opts, err := cfg.layer.surfaceOptions() | |||
| 56 | + | if err != nil { | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | a := &outputSet{ctx: ctx, opts: opts, width: int32(cfg.width), height: int32(cfg.height), transparent: cfg.transparent, view: view, | |||
| 60 | + | hooks: &inputHooks{onInput: cfg.onInput}, surfaces: map[*wayland.Window]*outputSurface{}, pending: map[*wayland.Window]uint32{}, | |||
| 61 | + | covered: map[uint32]bool{}, gpuWake: make(chan struct{}, 1)} | |||
| 62 | + | if cfg.styles != "" { | |||
| 63 | + | data, e := os.ReadFile(cfg.styles) | |||
| 64 | + | if e != nil { | |||
| 65 | + | return fmt.Errorf("nefergui: styles: %w", e) | |||
| 66 | + | } | |||
| 67 | + | a.sheet = css.Parse(string(data)) | |||
| 68 | + | } | |||
| 69 | + | if a.catalog, err = text.Load(text.SystemSource{}); err != nil { | |||
| 70 | + | return fmt.Errorf("nefergui: fonts: %w", err) | |||
| 71 | + | } | |||
| 72 | + | if len(a.catalog.Faces) == 0 { | |||
| 73 | + | return errors.New("nefergui: no usable fonts") | |||
| 74 | + | } | |||
| 75 | + | if a.conn, err = wayland.ConnectConnection(ctx, ""); err != nil { | |||
| 76 | + | return fmt.Errorf("nefergui: open platform: %w", err) | |||
| 77 | + | } | |||
| 78 | + | defer func() { | |||
| 79 | + | a.closeAll() | |||
| 80 | + | err = errors.Join(err, a.conn.Close()) | |||
| 81 | + | }() | |||
| 82 | + | // Surfaces for the outputs present now are configured before the reader | |||
| 83 | + | // starts; output events seen meanwhile are held and handled right after. | |||
| 84 | + | if err = a.reconcile(); err != nil { | |||
| 85 | + | return err | |||
| 86 | + | } | |||
| 87 | + | events := a.conn.StartReader() | |||
| 88 | + | for _, ev := range a.conn.TakeHeld() { | |||
| 89 | + | if err = a.handle(ev); err != nil { | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ext := make(chan struct{}, 1) | |||
| 94 | + | if cfg.wake != nil { | |||
| 95 | + | loopCtx, stop := context.WithCancel(ctx) | |||
| 96 | + | defer stop() | |||
| 97 | + | go forward(loopCtx, cfg.wake, ext) | |||
| 98 | + | } | |||
| 99 | + | return a.loop(events, ext) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // forward relays external wake tokens to the owner loop until ctx ends or ch | |||
| 103 | + | // closes; tokens coalesce. | |||
| 104 | + | func forward(ctx context.Context, ch <-chan struct{}, out chan<- struct{}) { | |||
| 105 | + | for { | |||
| 106 | + | select { | |||
| 107 | + | case <-ctx.Done(): | |||
| 108 | + | return | |||
| 109 | + | case _, ok := <-ch: | |||
| 110 | + | if !ok { | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | select { | |||
| 114 | + | case out <- struct{}{}: | |||
| 115 | + | default: | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (a *outputSet) loop(events <-chan wayland.Event, ext <-chan struct{}) error { | |||
| 122 | + | for { | |||
| 123 | + | if err := a.promote(); err != nil { | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | // Every Redraw runs on this goroutine, so draining each runtime's own | |||
| 127 | + | // wake here sees every request, including Build follow-ups. | |||
| 128 | + | a.drainWakes() | |||
| 129 | + | retry := false | |||
| 130 | + | for _, o := range a.surfaces { | |||
| 131 | + | again, err := o.p.Step() | |||
| 132 | + | if err != nil { | |||
| 133 | + | return err | |||
| 134 | + | } | |||
| 135 | + | retry = retry || again | |||
| 136 | + | } | |||
| 137 | + | if a.drainWakes() { | |||
| 138 | + | continue // a Build asked for a follow-up frame | |||
| 139 | + | } | |||
| 140 | + | var retryC <-chan time.Time | |||
| 141 | + | if retry { | |||
| 142 | + | retryC = time.After(5 * time.Millisecond) | |||
| 143 | + | } | |||
| 144 | + | select { | |||
| 145 | + | case <-a.ctx.Done(): | |||
| 146 | + | return a.ctx.Err() | |||
| 147 | + | case <-ext: | |||
| 148 | + | for _, o := range a.surfaces { | |||
| 149 | + | o.r.Redraw() | |||
| 150 | + | } | |||
| 151 | + | case <-a.gpuWake: | |||
| 152 | + | case <-retryC: | |||
| 153 | + | case ev := <-events: | |||
| 154 | + | if err := a.handle(ev); err != nil { | |||
| 155 | + | return err | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // drainWakes invalidates every surface whose runtime requested a frame and | |||
| 162 | + | // reports whether any did. | |||
| 163 | + | func (a *outputSet) drainWakes() bool { | |||
| 164 | + | woke := false | |||
| 165 | + | for _, o := range a.surfaces { | |||
| 166 | + | select { | |||
| 167 | + | case <-o.r.wake: | |||
| 168 | + | o.p.Invalidate() | |||
| 169 | + | woke = true | |||
| 170 | + | default: | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | return woke | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (a *outputSet) handle(ev wayland.Event) error { | |||
| 177 | + | switch ev.Kind { | |||
| 178 | + | case wayland.BufferRelease: | |||
| 179 | + | if o := a.surfaces[ev.Window]; o != nil { | |||
| 180 | + | return o.p.BufferReleased(ev.BufferID) | |||
| 181 | + | } | |||
| 182 | + | return nil | |||
| 183 | + | case wayland.OutputRemoved: | |||
| 184 | + | a.removeOutput(ev.Output.Global) | |||
| 185 | + | } | |||
| 186 | + | if err := a.conn.Apply(ev); err != nil { | |||
| 187 | + | return err | |||
| 188 | + | } | |||
| 189 | + | if w := ev.Window; w != nil && w.Closed { | |||
| 190 | + | // The compositor closed this surface (layer_surface.closed); the output | |||
| 191 | + | // stays covered so it is not recreated in a loop. | |||
| 192 | + | a.closeWindow(w) | |||
| 193 | + | return nil | |||
| 194 | + | } | |||
| 195 | + | switch ev.Kind { | |||
| 196 | + | case wayland.OutputGlobal, wayland.OutputNamed, wayland.OutputAdded: | |||
| 197 | + | return a.reconcile() | |||
| 198 | + | } | |||
| 199 | + | // Seat changes queue input (leave, focus-out) on every window. | |||
| 200 | + | for _, o := range a.surfaces { | |||
| 201 | + | if err := a.input(o); err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return nil | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // reconcile creates a layer surface for every named output without one. | |||
| 209 | + | func (a *outputSet) reconcile() error { | |||
| 210 | + | for _, out := range a.conn.Outputs() { | |||
| 211 | + | if a.covered[out.Global] { | |||
| 212 | + | continue | |||
| 213 | + | } | |||
| 214 | + | a.covered[out.Global] = true | |||
| 215 | + | l := *a.opts.Layer | |||
| 216 | + | l.OutputGlobal = out.Global | |||
| 217 | + | opts := a.opts | |||
| 218 | + | opts.Layer = &l | |||
| 219 | + | w, err := a.conn.NewWindow(a.ctx, a.width, a.height, a.transparent, opts) | |||
| 220 | + | if err != nil { | |||
| 221 | + | return fmt.Errorf("nefergui: layer surface for output %s: %w", out.Name, err) | |||
| 222 | + | } | |||
| 223 | + | a.pending[w] = out.Global | |||
| 224 | + | } | |||
| 225 | + | return nil | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // promote opens the render path of every configured window with complete | |||
| 229 | + | // dmabuf feedback, in ascending output order. | |||
| 230 | + | func (a *outputSet) promote() error { | |||
| 231 | + | ready := make([]*wayland.Window, 0, len(a.pending)) | |||
| 232 | + | for w := range a.pending { | |||
| 233 | + | if w.Configured && w.FeedbackDone { | |||
| 234 | + | ready = append(ready, w) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | slices.SortFunc(ready, func(x, y *wayland.Window) int { return cmp.Compare(a.pending[x], a.pending[y]) }) | |||
| 238 | + | for _, w := range ready { | |||
| 239 | + | output := a.pending[w] | |||
| 240 | + | delete(a.pending, w) | |||
| 241 | + | if w.Closed { // closed by the compositor before the reader started | |||
| 242 | + | _ = w.Close() | |||
| 243 | + | continue | |||
| 244 | + | } | |||
| 245 | + | o, err := a.open(w, output) // closes w on failure | |||
| 246 | + | if err != nil { | |||
| 247 | + | return fmt.Errorf("nefergui: layer render path: %w", err) | |||
| 248 | + | } | |||
| 249 | + | a.surfaces[w] = o | |||
| 250 | + | } | |||
| 251 | + | return nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (a *outputSet) open(w *wayland.Window, output uint32) (*outputSurface, error) { | |||
| 255 | + | s, err := session.OpenWindow(w, a.transparent) | |||
| 256 | + | if err != nil { | |||
| 257 | + | return nil, err | |||
| 258 | + | } | |||
| 259 | + | r := newRuntime() | |||
| 260 | + | r.styles = css.Compile(css.UA(), a.sheet) | |||
| 261 | + | r.setTextEngine(text.NewEngine(a.catalog)) | |||
| 262 | + | o := &outputSurface{w: w, output: output, r: r, s: s} | |||
| 263 | + | if o.p, err = s.NewPump(a.ctx, a.gpuWake, func() (render.Frame, bool, error) { return a.draw(o) }); err != nil { | |||
| 264 | + | _ = s.Close() | |||
| 265 | + | return nil, err | |||
| 266 | + | } | |||
| 267 | + | return o, nil | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func (a *outputSet) draw(o *outputSurface) (render.Frame, bool, error) { | |||
| 271 | + | w := o.w | |||
| 272 | + | o.r.route(platformInput{Kind: "resize", Width: float64(w.Width), Height: float64(w.Height), Scale: w.Scale}) | |||
| 273 | + | if !o.r.Build(a.view) { | |||
| 274 | + | return render.Frame{}, false, nil | |||
| 275 | + | } | |||
| 276 | + | if o.r.consumedInput() { | |||
| 277 | + | // Control events may have changed the shared model: the other outputs | |||
| 278 | + | // rebuild without events, so this cannot bounce back. | |||
| 279 | + | for _, other := range a.surfaces { | |||
| 280 | + | if other != o { | |||
| 281 | + | other.r.Redraw() | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | if err := o.r.applyInputRects(w.StageInputRects); err != nil { | |||
| 286 | + | return render.Frame{}, false, err | |||
| 287 | + | } | |||
| 288 | + | if err := w.SetCursor(cursorShape(o.r.cursor())); err != nil { | |||
| 289 | + | return render.Frame{}, false, err | |||
| 290 | + | } | |||
| 291 | + | pw, ph, err := w.PhysicalSize() | |||
| 292 | + | if err != nil { | |||
| 293 | + | return render.Frame{}, false, err | |||
| 294 | + | } | |||
| 295 | + | frame, err := o.s.Preparer.Prepare(o.r.output.Display, w.Scale, int(pw), int(ph)) | |||
| 296 | + | return frame, true, err | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // input routes pointer input to the surface's runtime. A raw hook that | |||
| 300 | + | // changed the model redraws every output, since they share it. | |||
| 301 | + | func (a *outputSet) input(o *outputSurface) error { | |||
| 302 | + | inputs := o.w.DrainInput(nil) | |||
| 303 | + | if len(inputs) == 0 { | |||
| 304 | + | return nil | |||
| 305 | + | } | |||
| 306 | + | for _, in := range inputs { | |||
| 307 | + | switch { | |||
| 308 | + | case in.Enter: | |||
| 309 | + | o.w.PointerEntered(in.Serial, in.PointerGen) | |||
| 310 | + | case in.Kind == "leave": | |||
| 311 | + | o.w.PointerLeft() | |||
| 312 | + | } | |||
| 313 | + | if a.hooks.input(in) { | |||
| 314 | + | for _, other := range a.surfaces { | |||
| 315 | + | other.r.Redraw() | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | o.r.routeInput(in) | |||
| 319 | + | } | |||
| 320 | + | o.p.Invalidate() | |||
| 321 | + | return o.w.SetCursor(cursorShape(o.r.cursor())) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func (a *outputSet) removeOutput(global uint32) { | |||
| 325 | + | delete(a.covered, global) | |||
| 326 | + | for w, output := range a.pending { | |||
| 327 | + | if output == global { | |||
| 328 | + | delete(a.pending, w) | |||
| 329 | + | _ = w.Close() | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | for w, o := range a.surfaces { | |||
| 333 | + | if o.output == global { | |||
| 334 | + | delete(a.surfaces, w) | |||
| 335 | + | o.close() | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func (a *outputSet) closeWindow(w *wayland.Window) { | |||
| 341 | + | if _, ok := a.pending[w]; ok { | |||
| 342 | + | delete(a.pending, w) | |||
| 343 | + | _ = w.Close() | |||
| 344 | + | } | |||
| 345 | + | if o := a.surfaces[w]; o != nil { | |||
| 346 | + | delete(a.surfaces, w) | |||
| 347 | + | o.close() | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func (o *outputSurface) close() { | |||
| 352 | + | o.p.Close() | |||
| 353 | + | _ = o.s.Close() | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func (a *outputSet) closeAll() { | |||
| 357 | + | for w, o := range a.surfaces { | |||
| 358 | + | delete(a.surfaces, w) | |||
| 359 | + | o.close() | |||
| 360 | + | } | |||
| 361 | + | for w := range a.pending { | |||
| 362 | + | delete(a.pending, w) | |||
| 363 | + | _ = w.Close() | |||
| 364 | + | } | |||
| 365 | + | } | |||
M internal/ui/runtime.go +15 -0
| @@ -52,6 +52,7 @@ type runtime struct { | |||
| 52 | 52 | pasteID uint64 | |
| 53 | 53 | ime edit.IME | |
| 54 | 54 | redraw bool | |
| 55 | + | consumed bool // the last committed build handled input events | |
| 55 | 56 | inputRects []Rect | |
| 56 | 57 | inputOut []wayland.Rect | |
| 57 | 58 | inputNil, inputStaged bool | |
| @@ -185,6 +186,7 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 185 | 186 | if len(events) > 0 { | |
| 186 | 187 | r.redraw = true | |
| 187 | 188 | } | |
| 189 | + | r.consumed = len(events) > 0 | |
| 188 | 190 | // Release every losing editor before enabling the winner: the IME port | |
| 189 | 191 | // represents one seat, not one port per editor. Never depend on map order. | |
| 190 | 192 | for id, editor := range r.edits { | |
| @@ -245,6 +247,13 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 245 | 247 | return true | |
| 246 | 248 | } | |
| 247 | 249 | ||
| 250 | + | // consumedInput reports whether the last committed build handled input events. | |
| 251 | + | func (r *runtime) consumedInput() bool { | |
| 252 | + | r.mu.Lock() | |
| 253 | + | defer r.mu.Unlock() | |
| 254 | + | return r.consumed | |
| 255 | + | } | |
| 256 | + | ||
| 248 | 257 | // Wait blocks until input/redraw is available or the context is cancelled. | |
| 249 | 258 | func (r *runtime) Wait(ctx context.Context) error { | |
| 250 | 259 | select { | |
| @@ -292,6 +301,12 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), | |||
| 292 | 301 | if cfg.width <= 0 || cfg.height <= 0 || int64(cfg.width) > 1<<31-1 || int64(cfg.height) > 1<<31-1 { | |
| 293 | 302 | return fmt.Errorf("nefergui: invalid size %dx%d", cfg.width, cfg.height) | |
| 294 | 303 | } | |
| 304 | + | if cfg.layer != nil && cfg.layer.AllOutputs { | |
| 305 | + | if committed != nil { | |
| 306 | + | return errors.New("nefergui: AllOutputs excludes RunFrames") | |
| 307 | + | } | |
| 308 | + | return runAllOutputs(ctx, cfg, func(f *Frame) { view(f, model) }) | |
| 309 | + | } | |
| 295 | 310 | r := newRuntime() | |
| 296 | 311 | if cfg.styles != "" { | |
| 297 | 312 | data, err := os.ReadFile(cfg.styles) | |
| @@ -52,6 +52,7 @@ type runtime struct { | |||||
| 52 | pasteID uint64 | 52 | pasteID uint64 | ||
| 53 | ime edit.IME | 53 | ime edit.IME | ||
| 54 | redraw bool | 54 | redraw bool | ||
| 55 | + | consumed bool // the last committed build handled input events | |||
| 55 | inputRects []Rect | 56 | inputRects []Rect | ||
| 56 | inputOut []wayland.Rect | 57 | inputOut []wayland.Rect | ||
| 57 | inputNil, inputStaged bool | 58 | inputNil, inputStaged bool | ||
| @@ -185,6 +186,7 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||||
| 185 | if len(events) > 0 { | 186 | if len(events) > 0 { | ||
| 186 | r.redraw = true | 187 | r.redraw = true | ||
| 187 | } | 188 | } | ||
| 189 | + | r.consumed = len(events) > 0 | |||
| 188 | // Release every losing editor before enabling the winner: the IME port | 190 | // Release every losing editor before enabling the winner: the IME port | ||
| 189 | // represents one seat, not one port per editor. Never depend on map order. | 191 | // represents one seat, not one port per editor. Never depend on map order. | ||
| 190 | for id, editor := range r.edits { | 192 | for id, editor := range r.edits { | ||
| @@ -245,6 +247,13 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||||
| 245 | return true | 247 | return true | ||
| 246 | } | 248 | } | ||
| 247 | 249 | ||||
| 250 | + | // consumedInput reports whether the last committed build handled input events. | |||
| 251 | + | func (r *runtime) consumedInput() bool { | |||
| 252 | + | r.mu.Lock() | |||
| 253 | + | defer r.mu.Unlock() | |||
| 254 | + | return r.consumed | |||
| 255 | + | } | |||
| 256 | + | ||||
| 248 | // Wait blocks until input/redraw is available or the context is cancelled. | 257 | // Wait blocks until input/redraw is available or the context is cancelled. | ||
| 249 | func (r *runtime) Wait(ctx context.Context) error { | 258 | func (r *runtime) Wait(ctx context.Context) error { | ||
| 250 | select { | 259 | select { | ||
| @@ -292,6 +301,12 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), | |||||
| 292 | if cfg.width <= 0 || cfg.height <= 0 || int64(cfg.width) > 1<<31-1 || int64(cfg.height) > 1<<31-1 { | 301 | if cfg.width <= 0 || cfg.height <= 0 || int64(cfg.width) > 1<<31-1 || int64(cfg.height) > 1<<31-1 { | ||
| 293 | return fmt.Errorf("nefergui: invalid size %dx%d", cfg.width, cfg.height) | 302 | return fmt.Errorf("nefergui: invalid size %dx%d", cfg.width, cfg.height) | ||
| 294 | } | 303 | } | ||
| 304 | + | if cfg.layer != nil && cfg.layer.AllOutputs { | |||
| 305 | + | if committed != nil { | |||
| 306 | + | return errors.New("nefergui: AllOutputs excludes RunFrames") | |||
| 307 | + | } | |||
| 308 | + | return runAllOutputs(ctx, cfg, func(f *Frame) { view(f, model) }) | |||
| 309 | + | } | |||
| 295 | r := newRuntime() | 310 | r := newRuntime() | ||
| 296 | if cfg.styles != "" { | 311 | if cfg.styles != "" { | ||
| 297 | data, err := os.ReadFile(cfg.styles) | 312 | data, err := os.ReadFile(cfg.styles) | ||