detached 8bbc355

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +490 -10
M docs/harness.md +1 -1
@@ -2,7 +2,7 @@
22
33 `go run ./cmd/nefergui-harness run --size 800x600 --scale 1.5 --layout fr --input script.txt --expect expect.json --out artifacts -- <client> [args...]`
44
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`.
66
77 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.
88
@@ -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 @@
22
33 `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.
44
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+
57 `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.
68
79 `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 (
1010 "strings"
1111 )
1212
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]*)*$`)
1416 var layoutPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
1517 var socketPattern = regexp.MustCompile(`^wayland-[0-9]+$`)
1618
@@ -26,7 +28,21 @@ func Config(size, scale, layout, background string) (string, error) {
2628 }
2729 // Wayland debug logs record each client cursor shape change, which
2830 // 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
3046 }
3147
3248 // 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, which29 // 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), nil31+ 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) {
9797 t.Fatalf("missing %s", s)
9898 }
9999 }
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"}} {
101108 if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil {
102109 t.Fatalf("accepted %v", tc)
103110 }
@@ -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) {
169169 }
170170 res.Artifacts[key] = path
171171 }
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")
173174 res.Artifacts["compositor_log"] = filepath.Join(out, "logs", "compositor.log")
174175 res.Artifacts["client_stdout"] = filepath.Join(out, "logs", "client.stdout")
175176 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) {
330331 }
331332 }
332333 if requestSeen {
333- err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath)
334+ err = copyNextFrame(shots, requestedAt, capturePath)
334335 if err == nil {
335336 res.Artifacts["capture"] = capturePath
336337 requestSeen = false
@@ -366,7 +367,7 @@ func Run(ctx context.Context, o Options) (res Result, code int) {
366367 }
367368 if requestSeen && err == nil {
368369 for runCtx.Err() == nil {
369- err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath)
370+ err = copyNextFrame(shots, requestedAt, capturePath)
370371 if err == nil {
371372 res.Artifacts["capture"] = capturePath
372373 break
@@ -169,7 +169,8 @@ func Run(ctx context.Context, o Options) (res Result, code int) {
169 }169 }
170 res.Artifacts[key] = path170 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"] = capturePath336 res.Artifacts["capture"] = capturePath
336 requestSeen = false337 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"] = capturePath372 res.Artifacts["capture"] = capturePath
372 break373 break
M internal/platform/wayland/layer.go +16 -1
@@ -62,6 +62,10 @@ type LayerOptions struct {
6262 // compositor choose its preferred output. A named output that is absent
6363 // fails the connection; a named output removed later closes the window.
6464 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
6569 // Namespace is the layer surface namespace; empty selects DefaultLayerNamespace.
6670 Namespace string
6771 // Anchor is a bit set of Anchor* values.
@@ -119,6 +123,8 @@ func (o LayerOptions) Validate() error {
119123 return errors.New("layer namespace contains NUL")
120124 case strings.ContainsRune(o.Output, 0):
121125 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")
122128 }
123129 return nil
124130 }
@@ -317,7 +323,16 @@ func (w *Window) createLayerSurface(o LayerOptions) error {
317323 return &CapabilityError{Name: layershell.LayerShellInterface, Cause: fmt.Errorf("on-demand keyboard requires version 4, compositor negotiated %d", w.LayerShell.Version())}
318324 }
319325 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 != "" {
321336 var global uint32
322337 var err error
323338 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 absent62 // 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 string64 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 string70 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 nil129 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.Output325 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 uint32336 var global uint32
322 var err error337 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) {
2828 }
2929
3030 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 {
3136 t.Helper()
3237 requireCompositor(t)
3338 dir := t.TempDir()
@@ -42,7 +47,7 @@ func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) [
4247 t.Setenv(headlessClientEnv, scenario)
4348 t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events)
4449 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",
4651 Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true,
4752 Timeout: timeout, ReadyTimeout: 5 * time.Second,
4853 Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"},
@@ -91,6 +96,25 @@ func TestHeadlessLayerHooks(t *testing.T) {
9196 }
9297 }
9398
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+
94118 func TestHeadlessSurfaceHookErrorAborts(t *testing.T) {
95119 lines := runHeadless(t, "hook-error", "", 20*time.Second)
96120 joined := strings.Join(lines, "\n")
@@ -160,6 +184,13 @@ func TestHeadlessClientProcess(t *testing.T) {
160184 return false
161185 }),
162186 )
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{}}))
163194 case "hook-error":
164195 options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
165196 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 false184 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) {
5858 "bad keyboard": {Level: LayerTop, Keyboard: 7},
5959 "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}},
6060 "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},
6163 } {
6264 if _, err := c.surfaceOptions(); err == nil {
6365 t.Errorf("%s: expected error", name)
@@ -185,6 +187,23 @@ func TestExternalWakeForcesBuild(t *testing.T) {
185187 }
186188 }
187189
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+
188207 func TestNodeRectAndFrameSize(t *testing.T) {
189208 r := newRuntime()
190209 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, 300209 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 }
5252 // edges); 0 reserves nothing and asks to avoid other surfaces' positive zones;
5353 // -1 ignores other exclusive zones and extends to the anchored output edges.
5454 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
5662 Namespace string // defaults to "nefergui"
5763 Level LayerLevel
5864 Anchors Anchor
@@ -67,6 +73,9 @@ type LayerConfig struct {
6773
6874 // surfaceOptions lowers the config to platform options and validates it.
6975 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+ }
7079 l := wayland.LayerOptions{Output: c.Output, Namespace: c.Namespace, ExclusiveZone: c.ExclusiveZone, Margin: c.Margin}
7180 switch c.Level {
7281 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 choose55+ 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 LayerLevel63 Level LayerLevel
58 Anchors Anchor64 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 {
5252 pasteID uint64
5353 ime edit.IME
5454 redraw bool
55+ consumed bool // the last committed build handled input events
5556 inputRects []Rect
5657 inputOut []wayland.Rect
5758 inputNil, inputStaged bool
@@ -185,6 +186,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
185186 if len(events) > 0 {
186187 r.redraw = true
187188 }
189+ r.consumed = len(events) > 0
188190 // Release every losing editor before enabling the winner: the IME port
189191 // represents one seat, not one port per editor. Never depend on map order.
190192 for id, editor := range r.edits {
@@ -245,6 +247,13 @@ func (r *runtime) Build(view func(*Frame)) bool {
245247 return true
246248 }
247249
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+
248257 // Wait blocks until input/redraw is available or the context is cancelled.
249258 func (r *runtime) Wait(ctx context.Context) error {
250259 select {
@@ -292,6 +301,12 @@ func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T),
292301 if cfg.width <= 0 || cfg.height <= 0 || int64(cfg.width) > 1<<31-1 || int64(cfg.height) > 1<<31-1 {
293302 return fmt.Errorf("nefergui: invalid size %dx%d", cfg.width, cfg.height)
294303 }
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+ }
295310 r := newRuntime()
296311 if cfg.styles != "" {
297312 data, err := os.ReadFile(cfg.styles)
@@ -52,6 +52,7 @@ type runtime struct {
52 pasteID uint6452 pasteID uint64
53 ime edit.IME53 ime edit.IME
54 redraw bool54 redraw bool
55+ consumed bool // the last committed build handled input events
55 inputRects []Rect56 inputRects []Rect
56 inputOut []wayland.Rect57 inputOut []wayland.Rect
57 inputNil, inputStaged bool58 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 = true187 r.redraw = true
187 }188 }
189+ r.consumed = len(events) > 0
188 // Release every losing editor before enabling the winner: the IME port190 // 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 true247 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)

Keyboard shortcuts

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

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

Branches 1

Tags 12

↑↓ moveenter openesc close