detached 21218d9

refactor!: remove Wayland code and Run/RunLock public API

Delete the Wayland platform, keyboard, harness and example code together
with Run, RunFrames, RunLock, window options, Layer, OnInput, OnResize,
OnSurface, Wake, SecretBuffer and WaylandSurface. Renderer is the only
presentation entry point.
commit
21218d9c9be1dc120e62662e30677e224fa7d502
author
brice · · 2026-10-01 17:43 UTC
parent
7f31652
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
76 files · +173 -17569
D cmd/nefergui-harness/main.go +0 -75
@@ -1,75 +0,0 @@
1-package main
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "flag"
8- "fmt"
9- "io"
10- "os"
11- "os/signal"
12- "path/filepath"
13- "strings"
14- "syscall"
15- "time"
16-
17- "github.com/bnema/nefergui/internal/harness"
18-)
19-
20-func main() { os.Exit(run(os.Args[1:])) }
21-func run(args []string) int {
22- if len(args) == 0 || args[0] != "run" {
23- fmt.Fprintln(os.Stderr, "usage: nefergui-harness run [--size WxH --scale 1.5 --layout fr --input script --expect expect.json --out dir --keep --allow-unpinned --timeout 15s] -- <client cmd...>")
24- return harness.Usage
25- }
26- f := flag.NewFlagSet("run", flag.ContinueOnError)
27- f.SetOutput(os.Stderr)
28- size := f.String("size", "800x600", "headless size")
29- scale := f.String("scale", "1", "output scale")
30- layout := f.String("layout", "us", "keyboard layout")
31- background := f.String("background", "#111111", "background #RRGGBB")
32- input := f.String("input", "", "input script")
33- expect := f.String("expect", "", "expectations JSON")
34- out := f.String("out", "", "new artifact directory")
35- keep := f.Bool("keep", false, "keep temporary runtime/config")
36- allowUnpinned := f.Bool("allow-unpinned", false, "permit NEFERGUI_NEFERWL override")
37- timeout := f.Duration("timeout", 15*time.Second, "maximum session duration")
38- ready := f.Duration("ready-timeout", 8*time.Second, "socket readiness deadline")
39- if err := f.Parse(args[1:]); err != nil {
40- return harness.Usage
41- }
42- if *out == "" {
43- fmt.Fprintln(os.Stderr, "--out required")
44- return harness.Usage
45- }
46- var ex harness.Expectations
47- if *expect != "" {
48- data, err := os.ReadFile(*expect)
49- if err != nil {
50- fmt.Fprintln(os.Stderr, err)
51- return harness.Usage
52- }
53- d := json.NewDecoder(strings.NewReader(string(data)))
54- d.DisallowUnknownFields()
55- if err = d.Decode(&ex); err != nil {
56- fmt.Fprintln(os.Stderr, err)
57- return harness.Usage
58- }
59- var extra any
60- if err = d.Decode(&extra); !errors.Is(err, io.EOF) {
61- fmt.Fprintln(os.Stderr, "unexpected JSON after expectations:", err)
62- return harness.Usage
63- }
64- }
65- base := "."
66- if *expect != "" {
67- base = filepath.Dir(*expect)
68- }
69- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
70- defer stop()
71- res, code := harness.Run(ctx, harness.Options{Size: *size, Scale: *scale, Layout: *layout, Background: *background, Input: *input, Out: *out, Keep: *keep, AllowUnpinned: *allowUnpinned, Timeout: *timeout, ReadyTimeout: *ready, Client: f.Args(), Expect: ex, GoldenBase: base})
72- b, _ := json.Marshal(res)
73- fmt.Println(string(b))
74- return code
75-}
D cmd/nefergui-harness/main_test.go +0 -36
@@ -1,36 +0,0 @@
1-package main
2-
3-import (
4- "os"
5- "path/filepath"
6- "testing"
7-)
8-
9-func TestUsage(t *testing.T) {
10- if code := run(nil); code != 2 {
11- t.Fatalf("missing run: %d", code)
12- }
13- if code := run([]string{"run"}); code != 2 {
14- t.Fatalf("missing out: %d", code)
15- }
16- dir := t.TempDir()
17- p := filepath.Join(dir, "expect.json")
18- for _, text := range []string{`{"probes":[],"unexpected":1}`, `{} {}`, `{"probes":[]}{`} {
19- if err := os.WriteFile(p, []byte(text), 0600); err != nil {
20- t.Fatal(err)
21- }
22- if code := run([]string{"run", "--expect", p, "--out", filepath.Join(dir, "out")}); code != 2 {
23- t.Fatalf("%q: %d", text, code)
24- }
25- }
26-}
27-func TestUnpinnedFlag(t *testing.T) {
28- t.Setenv("NEFERGUI_NEFERWL", "/bin/true")
29- dir := t.TempDir()
30- if code := run([]string{"run", "--out", filepath.Join(dir, "denied")}); code != 2 {
31- t.Fatalf("override without flag: %d", code)
32- }
33- if code := run([]string{"run", "--allow-unpinned", "--out", filepath.Join(dir, "allowed"), "--timeout", "1s", "--ready-timeout", "500ms"}); code == 2 {
34- t.Fatalf("override with flag: %d", code)
35- }
36-}
D docs/adr/0002-wayland-bindings-and-fd-ownership.md +0 -31
@@ -1,31 +0,0 @@
1-# ADR 0002 — Wayland bindings and file-descriptor ownership
2-
3-Status: accepted
4-
5-## Decision
6-
7-WLTurbo owns the Wayland wire transport, object registry, XML scanner and the canonical generated bindings. NeferGUI consumes generated packages and never writes protocol code.
8-
9-- `protocol/core` is generated from `wayland.xml`. Only `wl_display` and `wl_registry` bootstrap code is handwritten.
10-- Generated child objects keep their concrete type and typed event dispatch.
11-- Required extensions and the behavior when a global is missing:
12-
13-| Binding | Use | Missing global |
14-|---|---|---|
15-| xdg-shell | window lifecycle | fatal capability error |
16-| linux-dmabuf v4 feedback | format and device selection | fatal capability error |
17-| linux-drm-syncobj v1 | explicit synchronization | fatal capability error |
18-| viewporter | fractional buffer mapping | fatal when fractional scale is active |
19-| fractional-scale v1 | preferred scale | integer-scale fallback |
20-| text-input v3 | IME | IME unavailable, keyboard input still works |
21-| core data-device | clipboard | clipboard unavailable |
22-
23-Excluded: presentation-time, data-control, X11, compositor-private blur.
24-
25-## File descriptors
26-
27-- A request that sends an FD transfers ownership only after a complete successful send.
28-- A received message owns exactly the FDs declared by its signature. Socket reads are not message boundaries.
29-- Missing or extra FDs, truncated ancillary data, malformed payloads and unknown signatures are protocol errors. FD 0 is valid.
30-- Without a handler, owned FDs are closed. An owned FD reaches exactly one handler path.
31-- Decode, dispatch, cancellation, object destruction and shutdown close every unclaimed FD.
D docs/adr/0004-keyboard.md +0 -11
@@ -1,11 +0,0 @@
1-# ADR 0004 — Keyboard interpretation
2-
3-Status: accepted
4-
5-## Decision
6-
7-Keyboard interpretation uses `github.com/bnema/purego-xkbcommon`, which loads `libxkbcommon.so.0` at runtime without cgo. Raw bindings are generated from pinned upstream headers; a small handwritten layer owns reference counting, UTF-8 buffers, compose state and Wayland keymap FD ingestion. X11 APIs are not bound.
8-
9-NeferGUI handles keymap replacement, modifier and group masks, non-US layouts including AltGr, compose and dead keys, key repeat and its cancellation, shortcuts, and IME/keyboard deduplication. The compositor sets repeat rate and delay.
10-
11-While text-input v3 composition is active it takes precedence. Otherwise text comes from `xkb_state_key_get_utf8`.
D docs/harness.md +0 -23
@@ -1,23 +0,0 @@
1-# Visual harness
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...]`
4-
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-
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-
9-The session provides a private `XDG_RUNTIME_DIR`, NeferWL config, and discovered `WAYLAND_DISPLAY`; the compositor also gets a private `XDG_STATE_HOME`. The config enables NeferWL `wayland` debug logging; its structured run log is copied to `logs/compositor-run.log` and recorded as `artifacts.compositor_run_log` when NeferWL wrote one (a failed copy fails the run), where each client cursor shape change appears as a `cursor` entry. Host `DISPLAY`, `WAYLAND_DISPLAY`, and `WAYLAND_SOCKET` are removed from compositor and client environments. Clients also receive `NEFERGUI_DEBUG_DIR`. The harness only creates this directory; clients own their debug traces. To check a mapped surface before client shutdown, a client atomically publishes `client-debug/capture-request` (write a temporary file and rename) containing its wall-clock UnixNano immediately after committing its chosen frame, then remains mapped for at least two more compositor frames. NeferWL writes screenshots sequentially, so the first one finished after the timestamp may have been rendered before it; the harness selects the **second** `frame-NNNNNN.png` with modification time after that timestamp (retrying until it decodes completely), copies it to `frames/capture.png`, records `artifacts.capture`, and evaluates expectations against that capture. Without a request, expectations still use `frames/latest.png` (including compositor-only runs). A request without a later screenshot fails within the run timeout. Frames are in `frames/`, client debug files in `client-debug/`, logs in `logs/`, and machine-readable `result.json` uses schema `nefergui-harness/v1`. Run-specific paths and elapsed build/ready/client/total times are recorded. Temporary session directories disappear by default; artifacts remain.
10-
11-Example `expect.json`:
12-
13-```json
14-{
15- "probes": [{"name":"background", "x":40, "y":40, "color":"#336699", "tolerance":1}],
16- "regions": [{"name":"swatch", "rect":{"x":10,"y":10,"width":30,"height":20}, "color":"#336699", "tolerance":1}],
17- "goldens": [{"name":"screen", "path":"reference.png", "tolerance":2, "max_diff_ratio":0.001}]
18-}
19-```
20-
21-Colors are `#RRGGBB`; tolerance is an inclusive per-channel 8-bit delta including alpha. Rectangles use half-open pixel coordinates. A region without `color` checks bounds only. Golden paths are relative to the expectations file; bounds must match. Each golden emits `diff-N.png` (absolute RGB channel deltas with alpha delta shown in red, black where within tolerance). `max_diff_ratio` is the fraction of pixels with **any** channel outside tolerance. GPU sRGB conversion can differ from the configured background by one channel value, so background tests allow tolerance 1.
22-
23-Exit codes: 0 all checks pass, 1 an assertion fails, 2 invalid usage, 3 NeferWL/build/runtime unavailable, 4 client or compositor fails after readiness or fails to produce a frame. Run the opt-in compositor smoke test with `NEFERGUI_HARNESS=1 GOWORK=off go test -v ./internal/harness -run TestHeadlessBackground -count=1`.
D docs/session-lock.md +0 -50
@@ -1,50 +0,0 @@
1-# Session lock
2-
3-`nefergui.RunLock` implements the client side of `ext-session-lock-v1`. It acquires the lock, covers every output with opaque black and hosts the caller's view on one output. Authentication is the caller's; NeferGUI never unlocks on its own.
4-
5-## Behavior
6-
7-- One Wayland connection, seat and reader serve every lock surface. One lock surface is created per output present at acquisition; outputs added later get a black surface, removed outputs lose theirs.
8-- Rendering uses the normal Vulkan, DMA-BUF and explicit-sync path only: no shared-memory buffer, no readback, no implicit-sync fallback. A surface allocates and attaches a buffer only after its configure was acknowledged and its dmabuf feedback round completed (`Window.FeedbackDone`).
9-- Exactly one output hosts the view: the one whose surface has keyboard focus, initially the first. Every other output shows plain black (an empty frame).
10-- `OnLocked` fires when the compositor sends `locked`. `finished` (refused or ended) makes `RunLock` return `ErrLockFinished`; the lock is then destroyed with a plain `destroy`, never an unlock.
11-- Verification is the caller's and asynchronous: `OnSubmit` never blocks and never unlocks. Unlock happens only when the caller sends on `LockConfig.Unlock` (after its own verification); a request made before `locked` is held until `locked`, closing the channel is not a request. Then: `unlock_and_destroy`, a display roundtrip, and the connection closes. The caller shows progress with `LockConfig.Status` (`LockIdle`, `LockBusy`, `LockFailed`), surfaced as `LockState.Status`. Cancellation or any error closes the connection without unlocking, which keeps the session locked.
12-
13-## Configuration
14-
15-- `Display` is the Wayland socket name; empty uses the environment.
16-- `Wake` requests a redraw for each value received. Its forwarder is scoped to `RunLock` and stops when it returns.
17-- `OnOutputError(output, err)` reports, with the connector name, an output added after acquisition that could not get a lock surface or render path. Such hotplug failures are skipped (the compositor keeps the output black); failures on outputs present at acquisition abort `RunLock`.
18-- `NewSecretBuffer` panics unless the capacity is between 1 and 4096 bytes. `RunLock` wipes `Secret` when it returns, and the buffer is wiped after `OnSubmit` even if it panics.
19-- Outputs are promoted in ascending global order, so the first host is deterministic.
20-- Keyboard focus-out drops held keys and ignores key presses until focus returns; typed bytes stay in `Secret`, and input continues when any lock surface regains focus (that surface becomes the host).
21-
22-## Secret input
23-
24-Typed keys go through the keyboard secret path (`EventSecret`/`TickSecret`) into a caller-owned `SecretBuffer`. They never become a Go string, and never enter the model, the frame tree, render text, IME, pre-edit or the clipboard (lock surfaces bind neither). The view receives `LockState.Mask`, the number of typed code points, and draws bullets.
25-
26-Enter calls `OnSubmit` (after `locked` only; the buffer is wiped right after it returns, so it must copy or consume the slice and must not block), Escape and Ctrl+U clear, Backspace removes the last code point. Before `locked`, Enter only clears. Scratch buffers are zeroed after every key.
27-
28-## Example
29-
30-```go
31-secret := nefergui.NewSecretBuffer(256)
32-defer secret.Wipe()
33-err := nefergui.RunLock(ctx, nefergui.LockConfig{
34- Secret: secret,
35- Styles: "lock.css",
36- View: func(f *nefergui.Frame, s nefergui.LockState) {
37- box := f.Root(nefergui.Class("screen")).Column(nefergui.Class("box"))
38- box.Text(s.Bullets(), nefergui.Key("mask"))
39- },
40- Unlock: unlock, // chan struct{}: send after verification succeeded
41- Status: status, // chan nefergui.LockStatus: LockBusy / LockFailed / LockIdle
42- OnSubmit: func(pw []byte) { // copy or consume pw now; it is wiped on return
43- go verify(bytes.Clone(pw), unlock, status)
44- },
45-})
46-```
47-
48-## Verification
49-
50-Wire tests cover the shared connection (reader pause and queue saturation, keyboard and pointer generations, feedback completion). `NEFERGUI_HARNESS=1` with a NeferWL binary in `NEFERGUI_NEFERWL` runs the headless lock tests (`TestHarnessConnectionLock`, `TestHarnessRunLock`, `TestHarnessRunLockRefused`).
M example_test.go +37 -53
@@ -1,74 +1,58 @@
11 package nefergui_test
22
33 import (
4- "context"
54 "fmt"
65 "log"
76
87 "github.com/bnema/nefergui"
98 )
109
11-// The README counter. Run needs a Wayland session, so this example is
12-// compiled but not executed by go test.
13-func Example_counter() {
14- count := 0
15- view := func(f *nefergui.Frame, count *int) {
10+// ExampleRenderer shows the shape of a client loop. The Wayland side (display
11+// connection, linux-dmabuf feedback, buffer import, event dispatch) belongs to
12+// the application, for instance through github.com/bnema/neferclient; it is
13+// elided here. The example needs a GPU and a compositor, so it is compiled but
14+// not run by go test.
15+func ExampleRenderer() {
16+ type model struct{ count int }
17+ view := func(f *nefergui.Frame, m *model) {
1618 root := f.Root().Column()
1719 root.Text("Hello, Wayland!")
1820 if root.Button("Count", nefergui.Key("count")).Activated() {
19- *count++
21+ m.count++
2022 }
21- root.Text(fmt.Sprintf("Clicked %d times", *count))
23+ root.Text(fmt.Sprintf("Clicked %d times", m.count))
2224 }
23- if err := nefergui.Run(context.Background(), &count, view,
24- nefergui.Title("Hello"), nefergui.Size(320, 200)); err != nil {
25+
26+ // mainDevice and formats come from the compositor's linux-dmabuf feedback.
27+ var (
28+ mainDevice uint64
29+ formats []nefergui.Format
30+ )
31+ r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: mainDevice, Formats: formats})
32+ if err != nil {
2533 log.Fatal(err)
2634 }
27-}
35+ defer r.Close()
2836
29-// The product-design example. Run needs a Wayland session, so this example is
30-// compiled but not executed by go test; examples/demo runs it.
31-func Example_compileOnly() {
32- type Model struct {
33- Page, Name, Status string
34- DarkMode bool
35- }
36- model := Model{Page: "home", Status: "Prêt"}
37- navButton := func(parent nefergui.Node, m *Model, label, page string) {
38- opts := []nefergui.ButtonOption{nefergui.Key(page), nefergui.Class("menu-item")}
39- if m.Page == page {
40- opts = append(opts, nefergui.Class("active"))
41- }
42- if parent.Button(label, opts...).Activated() {
43- m.Page = page
44- }
45- }
46- home := func(parent nefergui.Node, m *Model) {
47- section := parent.Section(nefergui.Class("welcome"))
48- section.Heading("Bienvenue", nefergui.Level(2))
49- section.Text("Une interface native écrite entièrement en Go.")
50- section.Input("Votre nom", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Ada"))
51- actions := section.Row(nefergui.Class("actions"))
52- if actions.Button("Continuer", nefergui.Key("continue"), nefergui.Class("primary"), nefergui.Disabled(m.Name == "")).Activated() {
53- m.Status = "Bonjour " + m.Name
37+ // Forward the surface size, then wire events as they arrive.
38+ r.Resize(320, 200, 1)
39+ r.Input(&nefergui.Input{Kind: nefergui.InputPointerMotion, X: 10, Y: 10})
40+
41+ m := &model{}
42+ var out nefergui.Output
43+ for {
44+ drawn, err := r.Render(&out, m, view)
45+ if err != nil {
46+ log.Fatal(err)
5447 }
55- }
56- app := func(f *nefergui.Frame, m *Model) {
57- root := f.Root(nefergui.Class("app"))
58- header := root.Header(nefergui.Class("header"))
59- header.Heading("Demo", nefergui.Level(1), nefergui.Class("logo"))
60- menu := header.Nav(nefergui.Class("menu"))
61- navButton(menu, m, "Accueil", "home")
62- navButton(menu, m, "Paramètres", "settings")
63- header.Checkbox("Thème sombre", &m.DarkMode, nefergui.Key("dark-mode"))
64- content := root.Main(nefergui.Class("content"))
65- switch m.Page {
66- case "home":
67- home(content, m)
68- case "settings":
69- home(content, m)
48+ if drawn {
49+ // Import out.Planes as a wl_buffer when out.NewBuffer is set, attach
50+ // it with the Acquire/Release points, commit, and call r.Released
51+ // when out.ReleaseFD becomes readable.
52+ _ = out.Buffer
7053 }
71- root.Footer(nefergui.Class("status-bar")).Text(m.Status)
54+ // Sleep until a Wayland event, <-r.Wake(), or a short timer when
55+ // r.Pending() reports a frame waiting for the GPU.
56+ return
7257 }
73- _ = nefergui.Run(context.Background(), &model, app, nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles("app.css"))
7458 }
D examples/demo/main.go +0 -262
@@ -1,262 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "context"
7- _ "embed"
8- "encoding/json"
9- "errors"
10- "flag"
11- "fmt"
12- "math"
13- "os"
14- "os/signal"
15- "path/filepath"
16- "strconv"
17- "time"
18-
19- "github.com/bnema/nefergui"
20-)
21-
22-//go:embed app.css
23-var stylesheet []byte
24-
25-type Model struct {
26- Page, Name, Status string
27- Notes string
28- NewPending bool
29- DarkMode bool
30- Density string
31- Volume float64
32-}
33-
34-func navButton(parent nefergui.Node, m *Model, label, page string) {
35- opts := []nefergui.ButtonOption{nefergui.Key(page), nefergui.Class("menu-item")}
36- if m.Page == page {
37- opts = append(opts, nefergui.Class("active"))
38- }
39- if parent.Button(label, opts...).Activated() {
40- m.Page = page
41- }
42-}
43-func home(parent nefergui.Node, m *Model) {
44- workspace := parent.Row(nefergui.Class("workspace"))
45- editor := workspace.Column(nefergui.Class("editor-pane"))
46- tab := editor.Row(nefergui.Class("pane-bar"))
47- tab.Text("Untitled.txt", nefergui.Class("document-title"))
48- tab.Spacer(nefergui.Class("stretch"))
49- tab.Text("Plain text", nefergui.Class("hint"))
50- if editor.Textarea("Document", &m.Notes, nefergui.Key("document"), nefergui.Class("document-editor"), nefergui.Placeholder("Start writing…")).Changed() {
51- m.NewPending = false
52- m.Status = "Unsaved changes · session only"
53- }
54- inspector := workspace.Aside(nefergui.Class("inspector"))
55- inspector.Text("PROPERTIES", nefergui.Class("eyebrow"))
56- inspector.Text("Author", nefergui.Class("field-label"))
57- inspector.Input("Author", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Your name"))
58- if inspector.Button("Apply", nefergui.Key("continue"), nefergui.Disabled(m.Name == "")).Activated() {
59- m.Status = "Author set to " + m.Name + " · session only"
60- }
61- inspector.Separator()
62- inspector.Text("Interface density", nefergui.Class("field-label"))
63- for _, d := range []struct{ label, value string }{{"Comfortable", "comfortable"}, {"Compact", "compact"}} {
64- inspector.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value))
65- }
66- inspector.Separator()
67- inspector.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume"))
68- inspector.Spacer(nefergui.Class("stretch"))
69- inspector.Text("Changes stay in this session.", nefergui.Class("hint"))
70-}
71-func settings(parent nefergui.Node, m *Model) {
72- bar := parent.Row(nefergui.Class("pane-bar"))
73- bar.Text("Preferences", nefergui.Class("document-title"))
74- pane := parent.Column(nefergui.Class("preferences-pane"))
75- pane.Text("Appearance", nefergui.Class("field-label"))
76- pane.Checkbox("Dark mode", &m.DarkMode, nefergui.Key("appearance"))
77- pane.Text("Rendering", nefergui.Class("field-label"))
78- pane.Text("Wayland · Vulkan · UTF-8", nefergui.Class("muted"))
79- pane.Text("Document properties are available in the editor's right pane.", nefergui.Class("hint"))
80-}
81-
82-// requestNew arms confirmation without changing the document. A second request
83-// discards it; editing or Cancel disarms the pending request.
84-func (m *Model) requestNew() {
85- if m.Notes != "" && !m.NewPending {
86- m.NewPending = true
87- m.Status = "Click Discard & new to replace the current document"
88- return
89- }
90- m.Notes = ""
91- m.NewPending = false
92- m.Page = "home"
93- m.Status = "New document · session only"
94-}
95-
96-func app(f *nefergui.Frame, m *Model) {
97- theme := "light"
98- if m.DarkMode {
99- theme = "dark"
100- }
101- root := f.Root(nefergui.Class("app"), nefergui.Class(theme), nefergui.Class(m.Density))
102- header := root.Header(nefergui.Class("header"))
103- header.Heading("NeferGUI", nefergui.Level(1), nefergui.Class("logo"))
104- newLabel := "New"
105- if m.NewPending {
106- newLabel = "Discard & new"
107- }
108- if header.Button(newLabel, nefergui.Key("new-document")).Activated() {
109- m.requestNew()
110- }
111- if m.NewPending && header.Button("Cancel", nefergui.Key("cancel-new")).Activated() {
112- m.NewPending = false
113- m.Status = "Ready"
114- }
115- header.Text("Untitled.txt", nefergui.Class("muted"))
116- header.Spacer(nefergui.Class("stretch"))
117- label := "Dark mode"
118- if m.DarkMode {
119- label = "Light mode"
120- }
121- if header.Button(label, nefergui.Key("dark-mode"), nefergui.Class("theme-toggle")).Activated() {
122- m.DarkMode = !m.DarkMode
123- }
124- body := root.Row(nefergui.Class("body"))
125- sidebar := body.Aside(nefergui.Class("sidebar"))
126- sidebar.Text("DOCUMENTS", nefergui.Class("eyebrow"))
127- menu := sidebar.Nav(nefergui.Class("menu"))
128- navButton(menu, m, "Untitled.txt", "home")
129- navButton(menu, m, "Preferences", "settings")
130- sidebar.Spacer(nefergui.Class("stretch"))
131- sidebar.Text("Local workspace", nefergui.Class("sidebar-note"))
132- sidebar.Text("1 document · in memory", nefergui.Class("hint"))
133- content := body.Main(nefergui.Class("content"))
134- switch m.Page {
135- case "home":
136- home(content, m)
137- case "settings":
138- settings(content, m)
139- }
140- footer := root.Footer(nefergui.Class("status-bar"))
141- footer.Text(m.Status)
142- footer.Spacer(nefergui.Class("stretch"))
143- footer.Text("UTF-8 • Plain text", nefergui.Class("hint"))
144-}
145-func main() {
146- static := flag.Bool("static", false, "render a fixed number of frames and exit")
147- frames := flag.Int("frames", 120, "static frame count")
148- minDuration := flag.Duration("min-duration", 0, "static: keep presenting until at least this long after start (for scripted input)")
149- captureFrame := flag.Int("capture-frame", 0, "commit number for harness capture request")
150- flag.Parse()
151- if *static && (*frames < 2 || *captureFrame < 0 || *captureFrame >= *frames) {
152- fmt.Fprintln(os.Stderr, "invalid frame count or capture frame")
153- os.Exit(2)
154- }
155- if *captureFrame == 0 {
156- *captureFrame = *frames - 8
157- if *captureFrame < 1 {
158- *captureFrame = 1
159- }
160- }
161- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
162- defer stop()
163- if *static {
164- // Scripted runs must end even if the frame budget is never reached;
165- // the interactive demo runs until closed or interrupted.
166- var cancel context.CancelFunc
167- ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
168- defer cancel()
169- }
170- dir := os.Getenv("NEFERGUI_DEBUG_DIR")
171- var path string
172- if dir != "" {
173- path = filepath.Join(dir, "app.css")
174- if err := os.MkdirAll(dir, 0700); err != nil {
175- fail(err)
176- }
177- if err := os.WriteFile(path, stylesheet, 0600); err != nil {
178- fail(err)
179- }
180- } else {
181- file, err := os.CreateTemp("", "nefergui-demo-*.css")
182- if err != nil {
183- fail(err)
184- }
185- path = file.Name()
186- defer os.Remove(path)
187- if _, err = file.Write(stylesheet); err != nil {
188- file.Close()
189- fail(err)
190- }
191- if err = file.Close(); err != nil {
192- fail(err)
193- }
194- }
195- model := Model{Page: "home", Status: "Ready", Density: "comfortable", Volume: 40, Notes: "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session."}
196- start := time.Now()
197- var requested time.Time
198- commit := func(frame uint64) error {
199- if dir != "" {
200- data, err := json.Marshal(model)
201- if err != nil {
202- return err
203- }
204- if err = os.WriteFile(filepath.Join(dir, "demo-state.json"), data, 0600); err != nil {
205- return err
206- }
207- }
208- if *static && int(frame) == *captureFrame && dir != "" {
209- p := filepath.Join(dir, "capture-request")
210- tmp := p + ".tmp"
211- if err := os.WriteFile(tmp, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)+"\n"), 0600); err != nil {
212- return err
213- }
214- if err := os.Rename(tmp, p); err != nil {
215- return err
216- }
217- requested = time.Now()
218- }
219- if *static && int(frame) >= *frames && !requested.IsZero() {
220- time.Sleep(time.Until(requested.Add(250 * time.Millisecond)))
221- }
222- // Frame count alone depends on render speed: keep presenting (and
223- // handling input) until the minimum duration has also elapsed.
224- if *static && int(frame) >= *frames && time.Since(start) >= *minDuration {
225- return errDone
226- }
227- return nil
228- }
229- opts := []nefergui.WindowOption{nefergui.Title("NeferGUI — Untitled.txt"), nefergui.Size(960, 640), nefergui.Styles(path)}
230- var err error
231- if *static {
232- // The commit callback ends the run with errDone; the frame limit is a backstop.
233- err = nefergui.RunFrames(ctx, math.MaxInt32, &model, app, commit, opts...)
234- } else {
235- err = nefergui.Run(ctx, &model, app, opts...)
236- }
237- if err = unexpected(err); err != nil {
238- fail(err)
239- }
240-}
241-
242-var errDone = errors.New("demo: static run complete")
243-
244-// unexpected drops the normal stop reasons (errDone, cancellation) from a
245-// possibly joined error while keeping everything else, such as close errors.
246-func unexpected(err error) error {
247- if joined, ok := err.(interface{ Unwrap() []error }); ok {
248- var rest []error
249- for _, e := range joined.Unwrap() {
250- if e = unexpected(e); e != nil {
251- rest = append(rest, e)
252- }
253- }
254- return errors.Join(rest...)
255- }
256- if errors.Is(err, errDone) || errors.Is(err, context.Canceled) {
257- return nil
258- }
259- return err
260-}
261-
262-func fail(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
D examples/demo/main_test.go +0 -49
@@ -1,49 +0,0 @@
1-package main
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "testing"
8-)
9-
10-func TestNewDocumentRequiresConfirmation(t *testing.T) {
11- m := Model{Notes: "Keep this text", Page: "home"}
12- m.requestNew()
13- if m.Notes != "Keep this text" || !m.NewPending {
14- t.Fatalf("first request discarded text: %+v", m)
15- }
16- m.NewPending = false // Cancel
17- m.requestNew()
18- if m.Notes != "Keep this text" || !m.NewPending {
19- t.Fatalf("cancel failed to reset confirmation: %+v", m)
20- }
21- m.requestNew()
22- if m.Notes != "" || m.NewPending {
23- t.Fatalf("confirmation did not clear document: %+v", m)
24- }
25-}
26-
27-func TestUnexpectedKeepsCloseErrors(t *testing.T) {
28- closeErr := errors.New("close failed")
29- cases := []struct {
30- name string
31- in error
32- want error
33- }{
34- {"nil", nil, nil},
35- {"done", errDone, nil},
36- {"canceled", fmt.Errorf("run: %w", context.Canceled), nil},
37- {"done and close", errors.Join(errDone, closeErr), closeErr},
38- {"close only", errors.Join(nil, closeErr), closeErr},
39- }
40- for _, c := range cases {
41- got := unexpected(c.in)
42- if c.want == nil && got != nil || c.want != nil && !errors.Is(got, c.want) {
43- t.Errorf("%s: unexpected(%v) = %v, want %v", c.name, c.in, got, c.want)
44- }
45- }
46- if got := unexpected(errors.Join(errDone, closeErr)); errors.Is(got, errDone) {
47- t.Errorf("errDone kept in %v", got)
48- }
49-}
D examples/rect/main.go +0 -238
@@ -1,238 +0,0 @@
1-//go:build linux
2-
3-// Rect exercises presentation directly, without the immediate API.
4-package main
5-
6-import (
7- "context"
8- "errors"
9- "flag"
10- "fmt"
11- "image"
12- "image/color"
13- "math"
14- "os"
15- "os/signal"
16- "path/filepath"
17- "runtime"
18- "strconv"
19- "strings"
20- "time"
21-
22- "github.com/bnema/nefergui/internal/css"
23- "github.com/bnema/nefergui/internal/layout"
24- "github.com/bnema/nefergui/internal/presentation/session"
25- "github.com/bnema/nefergui/internal/presentation/vkdevice"
26- "github.com/bnema/nefergui/internal/render"
27- "github.com/bnema/nefergui/internal/text"
28-)
29-
30-func main() {
31- var frames, captureFrame int
32- var size, textString string
33- var textScale float64
34- var transparent, static, list, styles, imageMode, changingImage bool
35- flag.BoolVar(&static, "static", false, "fixed rectangle and color for visual acceptance tests")
36- flag.BoolVar(&list, "list", false, "draw three overlapping instanced quads")
37- flag.BoolVar(&styles, "styles", false, "draw CSS-style rounded border, outline and shadow")
38- flag.BoolVar(&imageMode, "image", false, "draw two scaled checkerboard images")
39- flag.BoolVar(&changingImage, "changing-image", false, "image mode: pass a new image value each frame")
40- flag.StringVar(&textString, "text", "", "draw glyphs using the GPU atlas")
41- flag.Float64Var(&textScale, "scale", 1, "text mode: physical pixels per logical pixel")
42- flag.IntVar(&frames, "frames", 0, "frames before exit (0 = until close)")
43- flag.IntVar(&captureFrame, "capture-frame", 0, "static: committed frame count for capture request (0 = frames-8)")
44- flag.StringVar(&size, "size", "400x300", "initial logical window width x height")
45- flag.BoolVar(&transparent, "transparent", false, "use ARGB and premultiplied transparency")
46- flag.Parse()
47- width, height, err := parseSize(size)
48- if err == nil && static && frames > 0 {
49- if captureFrame == 0 {
50- captureFrame = frames - 8
51- }
52- if captureFrame < 1 || captureFrame >= frames {
53- err = fmt.Errorf("--capture-frame must be between 1 and --frames-1")
54- }
55- }
56- if err == nil && (textString != "" || imageMode || styles) && (textScale <= 0 || math.IsNaN(textScale) || math.IsInf(textScale, 0)) {
57- err = fmt.Errorf("invalid text scale")
58- }
59- if err != nil {
60- fmt.Fprintln(os.Stderr, err)
61- os.Exit(2)
62- }
63- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
64- defer stop()
65- ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
66- defer cancel()
67- s, err := session.Open("", width, height, transparent)
68- if err != nil {
69- fmt.Fprintln(os.Stderr, "presentation:", err)
70- os.Exit(1)
71- }
72- closed := false
73- defer func() {
74- if !closed {
75- _ = s.Close()
76- }
77- }()
78- fmt.Printf("presentation: ready main_device=%d render=%d:%d logical=%dx%d physical=%dx%d transparent=%v\n", s.Window.MainDevice, s.Device.Render.Major, s.Device.Render.Minor, s.Window.Width, s.Window.Height, s.Width, s.Height, transparent)
79- var requested time.Time
80- draw := func(i int) vkdevice.Rect {
81- if static {
82- // The render pass clears to transparent black (#00000000). XRGB
83- // displays that as opaque black. Straight #ff8000 at alpha 1
84- // reads back as #ff8000ff; at alpha 0.5 the shader writes
85- // premultiplied RGB (~128,64,0) and alpha ~128.
86- alpha := float32(1)
87- if transparent {
88- alpha = 0.5
89- }
90- return vkdevice.Rect{Bounds: [4]float32{0.25, 0.25, 0.5, 0.5}, Color: [4]float32{1, 128.0 / 255, 0, alpha}}
91- }
92- phase := float64(i) * 0.08
93- alpha := float32(1)
94- if transparent {
95- alpha = 0.5
96- }
97- return vkdevice.Rect{Bounds: [4]float32{float32(0.25 + 0.2*math.Sin(phase)), 0.25, 0.5, 0.5}, Color: [4]float32{float32(0.5 + 0.5*math.Sin(phase)), 0.25, 0.8, alpha}}
98- }
99- committed := func(committed int) error {
100- if static && committed == captureFrame && os.Getenv("NEFERGUI_DEBUG_DIR") != "" {
101- // Publish only a complete timestamp: the harness may observe this
102- // file while the client is still presenting subsequent frames.
103- dir := os.Getenv("NEFERGUI_DEBUG_DIR")
104- path := filepath.Join(dir, "capture-request")
105- tmp := path + ".tmp"
106- if err := os.WriteFile(tmp, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)+"\n"), 0600); err != nil {
107- return err
108- }
109- if err := os.Rename(tmp, path); err != nil {
110- return err
111- }
112- requested = time.Now()
113- }
114- // Stay mapped long enough for the harness to see two later screenshots.
115- if static && committed == frames && !requested.IsZero() {
116- time.Sleep(time.Until(requested.Add(250 * time.Millisecond)))
117- }
118- return nil
119- }
120- var count int
121- if imageMode {
122- count, err = s.RunPreparedListWithCommit(ctx, frames, func(i int) (render.Frame, error) {
123- return s.Preparer.Prepare(imageCommands(i, changingImage, float64(s.Width)/textScale, float64(s.Height)/textScale), textScale, int(s.Width), int(s.Height))
124- }, committed)
125- } else if textString != "" {
126- _, source, _, _ := runtime.Caller(0)
127- catalog, e := text.Load(text.DirectorySource(filepath.Join(filepath.Dir(source), "..", "..", "testdata", "fonts")))
128- if e != nil {
129- fmt.Fprintln(os.Stderr, "text fonts:", e)
130- os.Exit(1)
131- }
132- measured, e := text.NewEngine(catalog).Measure(textString, text.Request{Families: []string{"Noto Sans"}, Size: 32}, 0)
133- if e != nil {
134- fmt.Fprintln(os.Stderr, "text measure:", e)
135- os.Exit(1)
136- }
137- cmd := layout.Command{Op: "text", Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}
138- for _, line := range measured.Lines {
139- for _, shaped := range line.Runs {
140- r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 32}
141- for _, g := range shaped.Glyphs {
142- r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 20 + g.X, Y: 40 + g.Y})
143- }
144- cmd.Runs = append(cmd.Runs, r)
145- }
146- }
147- count, err = s.RunPreparedListWithCommit(ctx, frames, func(int) (render.Frame, error) {
148- return s.Preparer.Prepare([]layout.Command{{Op: "rect", Rect: layout.Rect{W: float64(s.Width) / textScale, H: float64(s.Height) / textScale}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1}, cmd}, textScale, int(s.Width), int(s.Height))
149- }, committed)
150- } else if styles {
151- count, err = s.RunPreparedListWithCommit(ctx, frames, func(int) (render.Frame, error) {
152- return s.Preparer.Prepare(styleCommands(), textScale, int(s.Width), int(s.Height))
153- }, committed)
154- } else if list {
155- count, err = s.RunListWithCommit(ctx, frames, func(int) []vkdevice.Instance { return listQuads(s.Width, s.Height) }, committed)
156- } else {
157- count, err = s.RunWithCommit(ctx, frames, draw, committed)
158- }
159- windowClosed := s.Window.Closed
160- closed = true
161- if cerr := s.Close(); cerr != nil && (err == nil || errors.Is(err, context.Canceled)) {
162- err = cerr
163- }
164- fmt.Printf("presentation: frames=%d closed=%v err=%v\n", count, windowClosed, err)
165- if err != nil && !errors.Is(err, context.Canceled) {
166- os.Exit(1)
167- }
168-}
169-
170-// imageCommands mixes ordinary and image quads to exercise painter's order.
171-func imageCommands(frame int, changing bool, width, height float64) []layout.Command {
172- checker := image.NewNRGBA(image.Rect(0, 0, 2, 2))
173- checker.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
174- checker.SetNRGBA(1, 0, color.NRGBA{G: 255, A: 255})
175- checker.SetNRGBA(0, 1, color.NRGBA{B: 255, A: 255})
176- checker.SetNRGBA(1, 1, color.NRGBA{R: 255, G: 255, A: 255})
177- if changing {
178- checker.Pix[0] = uint8(frame)
179- }
180- second := image.NewNRGBA(image.Rect(0, 0, 2, 2))
181- for y := 0; y < 2; y++ {
182- for x := 0; x < 2; x++ {
183- second.SetNRGBA(x, y, color.NRGBA{R: 255, G: 128, A: 255})
184- }
185- }
186- return []layout.Command{
187- {Op: "rect", Rect: layout.Rect{W: width, H: height}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1},
188- {Op: "image", Rect: layout.Rect{X: 100, Y: 100, W: 64, H: 64}, Radii: [4]float64{8, 8, 8, 8}, Image: checker, Opacity: .5},
189- {Op: "rect", Rect: layout.Rect{X: 180, Y: 100, W: 20, H: 20}, Color: css.Color{R: 1, A: 1}, Opacity: 1},
190- {Op: "image", Rect: layout.Rect{X: 210, Y: 100, W: 64, H: 64}, Image: second, Opacity: 1},
191- }
192-}
193-
194-func parseSize(size string) (int32, int32, error) {
195- w, h, ok := strings.Cut(size, "x")
196- if !ok {
197- return 0, 0, fmt.Errorf("invalid --size %q", size)
198- }
199- x, err := strconv.ParseInt(w, 10, 32)
200- if err != nil || x <= 0 {
201- return 0, 0, fmt.Errorf("invalid --size %q", size)
202- }
203- y, err := strconv.ParseInt(h, 10, 32)
204- if err != nil || y <= 0 {
205- return 0, 0, fmt.Errorf("invalid --size %q", size)
206- }
207- return int32(x), int32(y), nil
208-}
209-
210-// listQuads uses physical pixels and keeps each probe away from antialiased edges.
211-func listQuads(w, h int32) []vkdevice.Instance {
212- clip := [4]float32{0, 0, float32(w), float32(h)}
213- return []vkdevice.Instance{
214- {Bounds: [4]float32{100, 100, 200, 200}, Clip: clip, Radii: [4]float32{20, 20, 20, 20}, Color: [4]float32{1, 0, 0, 1}},
215- {Bounds: [4]float32{220, 120, 200, 200}, Clip: [4]float32{220, 120, 100, 200}, Color: [4]float32{0, 1, 0, 1}},
216- {Bounds: [4]float32{140, 180, 100, 100}, Clip: clip, Color: [4]float32{0, 0, 1, .5}},
217- }
218-}
219-
220-// styleCommands uses logical coordinates; --scale converts them to physical.
221-// Outset shadows precede the fill, while outlines follow the border.
222-func styleCommands() []layout.Command {
223- box := layout.Rect{X: 120, Y: 120, W: 160, H: 120}
224- radius := [4]float64{20, 20, 20, 20}
225- sh := css.Shadow{X: css.Length{Value: 0, Unit: "px"}, Y: css.Length{Value: 8, Unit: "px"}, Blur: css.Length{Value: 12, Unit: "px"}, Color: css.Color{A: .9}}
226- return []layout.Command{
227- {Op: "rect", Rect: layout.Rect{W: 800, H: 600}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1},
228- {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1},
229- {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1},
230- {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
231- // Zero-width sides retain deliberately contrasting colors to catch
232- // accidental corner contributions from unpainted edges.
233- {Op: "border", Rect: layout.Rect{X: 300, Y: 40, W: 80, H: 50}, Widths: layout.Edges{Top: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{G: 1, A: 1}, Right: css.Color{G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
234- {Op: "border", Rect: layout.Rect{X: 300, Y: 240, W: 80, H: 50}, Widths: layout.Edges{Top: 8, Left: 2}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{B: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
235- // Layout has already expanded this rect by the 4px outline offset.
236- {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1},
237- }
238-}
D internal/harness/assert.go +0 -166
@@ -1,166 +0,0 @@
1-package harness
2-
3-import (
4- "fmt"
5- "image"
6- "image/color"
7- "image/draw"
8- "image/png"
9- "os"
10- "regexp"
11-)
12-
13-// Rect uses half-open pixel coordinates.
14-type Rect struct {
15- X int `json:"x"`
16- Y int `json:"y"`
17- Width int `json:"width"`
18- Height int `json:"height"`
19-}
20-type Check struct {
21- Name string `json:"name"`
22- Pass bool `json:"pass"`
23- Detail string `json:"detail"`
24-}
25-type Probe struct {
26- Name string `json:"name"`
27- X int `json:"x"`
28- Y int `json:"y"`
29- Color string `json:"color"`
30- Tolerance uint8 `json:"tolerance"`
31-}
32-type Region struct {
33- Name string `json:"name"`
34- Rect Rect `json:"rect"`
35- Color string `json:"color,omitempty"`
36- Tolerance uint8 `json:"tolerance,omitempty"`
37-}
38-type Golden struct {
39- Name string `json:"name"`
40- Path string `json:"path"`
41- Tolerance uint8 `json:"tolerance"`
42- MaxDiffRatio float64 `json:"max_diff_ratio"`
43-}
44-type Expectations struct {
45- Probes []Probe `json:"probes"`
46- Regions []Region `json:"regions"`
47- Goldens []Golden `json:"goldens"`
48-}
49-
50-func LoadPNG(path string) (image.Image, error) {
51- f, err := os.Open(path)
52- if err != nil {
53- return nil, err
54- }
55- defer f.Close()
56- return png.Decode(f)
57-}
58-
59-var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
60-
61-func ParseColor(s string) (color.NRGBA, error) {
62- var r, g, b uint8
63- if !hexColor.MatchString(s) {
64- return color.NRGBA{}, fmt.Errorf("color %q: expected #RRGGBB", s)
65- }
66- if _, err := fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b); err != nil {
67- return color.NRGBA{}, fmt.Errorf("color %q: %w", s, err)
68- }
69- return color.NRGBA{r, g, b, 255}, nil
70-}
71-func channels(c color.Color) color.NRGBA { return color.NRGBAModel.Convert(c).(color.NRGBA) }
72-func closeColor(a, b color.Color, t uint8) bool {
73- x, y := channels(a), channels(b)
74- for _, v := range [][2]uint8{{x.R, y.R}, {x.G, y.G}, {x.B, y.B}, {x.A, y.A}} {
75- d := int(v[0]) - int(v[1])
76- if d < 0 {
77- d = -d
78- }
79- if d > int(t) {
80- return false
81- }
82- }
83- return true
84-}
85-func BoundsCheck(img image.Image, r Rect) error {
86- b := img.Bounds()
87- if r.Width <= 0 || r.Height <= 0 || r.X < b.Min.X || r.Y < b.Min.Y || r.X > b.Max.X-r.Width || r.Y > b.Max.Y-r.Height {
88- return fmt.Errorf("region %+v outside image %v or empty", r, b)
89- }
90- return nil
91-}
92-func PixelProbe(img image.Image, x, y int, want color.Color, t uint8) error {
93- if !image.Pt(x, y).In(img.Bounds()) {
94- return fmt.Errorf("pixel (%d,%d) outside %v", x, y, img.Bounds())
95- }
96- got := img.At(x, y)
97- if !closeColor(got, want, t) {
98- return fmt.Errorf("pixel (%d,%d): got %v, want %v (tolerance %d)", x, y, channels(got), channels(want), t)
99- }
100- return nil
101-}
102-func UniformRegion(img image.Image, r Rect, want color.Color, t uint8) error {
103- if err := BoundsCheck(img, r); err != nil {
104- return err
105- }
106- for y := r.Y; y < r.Y+r.Height; y++ {
107- for x := r.X; x < r.X+r.Width; x++ {
108- if err := PixelProbe(img, x, y, want, t); err != nil {
109- return err
110- }
111- }
112- }
113- return nil
114-}
115-
116-// CompareGolden produces an opaque heatmap for every pixel, even on a mismatch.
117-// Pixels within tolerance are black; differing pixels show absolute RGB deltas,
118-// with alpha delta in the red channel so alpha-only changes remain visible.
119-func CompareGolden(got, want image.Image, t uint8, maxRatio float64) (bool, float64, *image.NRGBA, error) {
120- if maxRatio < 0 || maxRatio > 1 {
121- return false, 0, nil, fmt.Errorf("max_diff_ratio must be between 0 and 1")
122- }
123- if got.Bounds() != want.Bounds() {
124- return false, 0, nil, fmt.Errorf("bounds differ: %v vs %v", got.Bounds(), want.Bounds())
125- }
126- b := got.Bounds()
127- if b.Empty() {
128- return false, 0, nil, fmt.Errorf("empty golden")
129- }
130- diff := image.NewNRGBA(b)
131- draw.Draw(diff, b, &image.Uniform{C: color.NRGBA{0, 0, 0, 255}}, image.Point{}, draw.Src)
132- n := 0
133- for y := b.Min.Y; y < b.Max.Y; y++ {
134- for x := b.Min.X; x < b.Max.X; x++ {
135- a, c := channels(got.At(x, y)), channels(want.At(x, y))
136- if closeColor(a, c, t) {
137- continue
138- }
139- n++
140- delta := func(a, b uint8) uint8 {
141- if a > b {
142- return a - b
143- }
144- return b - a
145- }
146- r := delta(a.R, c.R)
147- if alpha := delta(a.A, c.A); alpha > r {
148- r = alpha
149- }
150- diff.SetNRGBA(x, y, color.NRGBA{r, delta(a.G, c.G), delta(a.B, c.B), 255})
151- }
152- }
153- ratio := float64(n) / float64(b.Dx()*b.Dy())
154- return ratio <= maxRatio, ratio, diff, nil
155-}
156-func SavePNG(path string, img image.Image) error {
157- f, err := os.Create(path)
158- if err != nil {
159- return err
160- }
161- if err = png.Encode(f, img); err != nil {
162- _ = f.Close()
163- return err
164- }
165- return f.Close()
166-}
D internal/harness/build.go +0 -95
@@ -1,95 +0,0 @@
1-package harness
2-
3-import (
4- "context"
5- "fmt"
6- "os"
7- "os/exec"
8- "path/filepath"
9- "strings"
10- "syscall"
11-)
12-
13-const NeferWLSHA = "1d16b83bf90f9e8189ad21b27772382c96b9f9a8"
14-
15-func NeferWL(ctx context.Context) (string, error) {
16- if err := ctx.Err(); err != nil {
17- return "", err
18- }
19- if path := os.Getenv("NEFERGUI_NEFERWL"); path != "" {
20- if fi, err := os.Stat(path); err != nil || fi.IsDir() || fi.Mode()&0111 == 0 {
21- return "", fmt.Errorf("NEFERGUI_NEFERWL is not executable: %s", path)
22- }
23- return path, nil
24- }
25- cache := os.Getenv("XDG_CACHE_HOME")
26- if cache == "" {
27- var err error
28- cache, err = os.UserCacheDir()
29- if err != nil {
30- return "", err
31- }
32- }
33- dir := filepath.Join(cache, "nefergui", "harness", "neferwl-"+NeferWLSHA)
34- bin := filepath.Join(dir, "neferwl")
35- if fi, err := os.Stat(bin); err == nil && fi.Mode().IsRegular() && fi.Mode()&0111 != 0 {
36- return bin, nil
37- }
38- if err := os.MkdirAll(dir, 0700); err != nil {
39- return "", err
40- }
41- if err := ctx.Err(); err != nil {
42- return "", err
43- }
44- stage, err := os.MkdirTemp(dir, "build-")
45- if err != nil {
46- return "", err
47- }
48- defer os.RemoveAll(stage)
49- cmd := exec.Command("go", "install", "github.com/bnema/neferwl/cmd/neferwl@"+NeferWLSHA)
50- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
51- cmd.Env = withEnv(os.Environ(), map[string]string{"GOWORK": "off", "GOBIN": stage})
52- stop := context.AfterFunc(ctx, func() {
53- if cmd.Process != nil {
54- _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
55- }
56- })
57- output, err := cmd.CombinedOutput()
58- stop()
59- if ctx.Err() != nil {
60- return "", ctx.Err()
61- }
62- if err != nil {
63- return "", fmt.Errorf("neferwl build: %w: %s", err, strings.TrimSpace(string(output)))
64- }
65- if err := os.Rename(filepath.Join(stage, "neferwl"), bin); err != nil {
66- return "", err
67- }
68- return bin, nil
69-}
70-
71-// isolatedEnv drops host display credentials before session-specific values are added.
72-func isolatedEnv(base []string) []string {
73- out := make([]string, 0, len(base))
74- for _, e := range base {
75- k, _, _ := strings.Cut(e, "=")
76- if k != "DISPLAY" && k != "WAYLAND_DISPLAY" && k != "WAYLAND_SOCKET" {
77- out = append(out, e)
78- }
79- }
80- return out
81-}
82-func withEnv(base []string, overrides map[string]string) []string {
83- out := make([]string, 0, len(base)+len(overrides))
84- for _, e := range base {
85- k, _, _ := strings.Cut(e, "=")
86- if _, ok := overrides[k]; ok || k == "WAYLAND_SOCKET" {
87- continue
88- }
89- out = append(out, e)
90- }
91- for k, v := range overrides {
92- out = append(out, k+"="+v)
93- }
94- return out
95-}
D internal/harness/capture_test.go +0 -77
@@ -1,77 +0,0 @@
1-package harness
2-
3-import (
4- "errors"
5- "image"
6- "os"
7- "path/filepath"
8- "strconv"
9- "testing"
10- "time"
11-)
12-
13-func TestCaptureSelectsByCommitTimestamp(t *testing.T) {
14- dir := t.TempDir()
15- token := time.Now().Add(-time.Minute).Truncate(time.Second)
16- for _, tc := range []struct {
17- name string
18- mtime time.Time
19- complete bool
20- }{
21- {"frame-000001.png", token.Add(-time.Second), true},
22- {"frame-000002.png", token.Add(time.Second), true},
23- {"frame-000003.png", token.Add(2 * time.Second), false},
24- {"frame-000004.png", token.Add(3 * time.Second), true},
25- {"latest.png", token.Add(4 * time.Second), true},
26- } {
27- path := filepath.Join(dir, tc.name)
28- if tc.complete {
29- if err := SavePNG(path, image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil {
30- t.Fatal(err)
31- }
32- } else if err := os.WriteFile(path, []byte("incomplete"), 0600); err != nil {
33- t.Fatal(err)
34- }
35- if err := os.Chtimes(path, tc.mtime, tc.mtime); err != nil {
36- t.Fatal(err)
37- }
38- }
39- request := filepath.Join(dir, "capture-request")
40- if err := os.WriteFile(request, []byte(strconv.FormatInt(token.UnixNano(), 10)), 0600); err != nil {
41- t.Fatal(err)
42- }
43- after, err := captureRequestTime(request)
44- if err != nil || !after.Equal(token) {
45- t.Fatalf("token %v %v", after, err)
46- }
47- // frame-2 finished after the token but may have been rendered before it.
48- name, err := nextFrame(dir, after)
49- if err != nil || name != "frame-000003.png" {
50- t.Fatalf("selected %q %v", name, err)
51- }
52- if name, err := nextFrame(dir, token.Add(2500*time.Millisecond)); err != nil || name != "" {
53- t.Fatalf("one screenshot after the token must not be selected: %q %v", name, err)
54- }
55- capture := filepath.Join(dir, "capture.png")
56- if err := copyNextFrame(dir, after, capture); !errors.Is(err, os.ErrNotExist) {
57- t.Fatalf("incomplete frame accepted: %v", err)
58- }
59- if err := SavePNG(filepath.Join(dir, name), image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil {
60- t.Fatal(err)
61- }
62- if err := os.Chtimes(filepath.Join(dir, name), token.Add(2*time.Second), token.Add(2*time.Second)); err != nil {
63- t.Fatal(err)
64- }
65- if err := copyNextFrame(dir, after, capture); err != nil {
66- t.Fatal(err)
67- }
68- if got, err := LoadPNG(capture); err != nil || got.Bounds().Dx() != 2 {
69- t.Fatalf("capture %v %v", got, err)
70- }
71- if err := os.WriteFile(request, []byte("not-a-time"), 0600); err != nil {
72- t.Fatal(err)
73- }
74- if _, err := captureRequestTime(request); err == nil {
75- t.Fatal("invalid token accepted")
76- }
77-}
D internal/harness/config.go +0 -83
@@ -1,83 +0,0 @@
1-package harness
2-
3-import (
4- "context"
5- "fmt"
6- "net"
7- "os"
8- "path/filepath"
9- "regexp"
10- "strings"
11-)
12-
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]*)*$`)
16-var layoutPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
17-var socketPattern = regexp.MustCompile(`^wayland-[0-9]+$`)
18-
19-func Config(size, scale, layout, background string) (string, error) {
20- if !sizePattern.MatchString(size) || !layoutPattern.MatchString(layout) {
21- return "", fmt.Errorf("invalid size or layout")
22- }
23- if scale != "1" && scale != "1.25" && scale != "1.5" && scale != "2" {
24- return "", fmt.Errorf("unsupported scale %q", scale)
25- }
26- if _, err := ParseColor(background); err != nil {
27- return "", err
28- }
29- // Wayland debug logs record each client cursor shape change, which
30- // systemtests read from the compositor run log.
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
46-}
47-
48-// DiscoverSocket only accepts a real Unix socket paired with NeferWL's state file.
49-// A fresh private runtime directory prevents picking up a foreign session.
50-func DiscoverSocket(ctx context.Context, runtime string) (string, error) {
51- entries, err := os.ReadDir(filepath.Join(runtime, "neferwl"))
52- if os.IsNotExist(err) {
53- return "", nil
54- }
55- if err != nil {
56- return "", err
57- }
58- var names []string
59- for _, e := range entries {
60- if err := ctx.Err(); err != nil {
61- return "", err
62- }
63- name, ok := strings.CutSuffix(e.Name(), ".json")
64- if !ok || !socketPattern.MatchString(name) {
65- continue
66- }
67- fi, err := os.Lstat(filepath.Join(runtime, name))
68- if err == nil && fi.Mode()&os.ModeSocket != 0 {
69- c, err := (&net.Dialer{}).DialContext(ctx, "unix", filepath.Join(runtime, name))
70- if err == nil {
71- _ = c.Close()
72- names = append(names, name)
73- }
74- }
75- }
76- if len(names) > 1 {
77- return "", fmt.Errorf("multiple compositor sockets: %v", names)
78- }
79- if len(names) == 1 {
80- return names[0], nil
81- }
82- return "", nil
83-}
D internal/harness/harness_test.go +0 -338
@@ -1,338 +0,0 @@
1-package harness
2-
3-import (
4- "context"
5- "image"
6- "image/color"
7- "net"
8- "os"
9- "path/filepath"
10- "strings"
11- "syscall"
12- "testing"
13- "time"
14-)
15-
16-func TestAssertions(t *testing.T) {
17- img := image.NewNRGBA(image.Rect(4, 5, 6, 7))
18- c := color.NRGBA{51, 102, 153, 255}
19- for y := 5; y < 7; y++ {
20- for x := 4; x < 6; x++ {
21- img.Set(x, y, c)
22- }
23- }
24- for _, tc := range []struct {
25- name string
26- err error
27- pass bool
28- }{
29- {"probe", PixelProbe(img, 4, 5, c, 0), true}, {"outside", PixelProbe(img, 0, 0, c, 0), false},
30- {"tolerated", PixelProbe(img, 4, 5, color.NRGBA{52, 102, 153, 255}, 1), true},
31- {"color", UniformRegion(img, Rect{4, 5, 2, 2}, c, 0), true},
32- {"bounds", BoundsCheck(img, Rect{4, 5, 3, 2}), false}, {"empty", BoundsCheck(img, Rect{4, 5, 0, 2}), false},
33- } {
34- t.Run(tc.name, func(t *testing.T) {
35- if (tc.err == nil) != tc.pass {
36- t.Fatalf("error %v, want pass %v", tc.err, tc.pass)
37- }
38- })
39- }
40- changed := image.NewNRGBA(img.Bounds())
41- for y := 5; y < 7; y++ {
42- for x := 4; x < 6; x++ {
43- changed.Set(x, y, c)
44- }
45- }
46- changed.Set(4, 5, color.NRGBA{54, 102, 153, 255})
47- for _, tc := range []struct {
48- tolerance uint8
49- max float64
50- pass bool
51- ratio float64
52- }{{0, .24, false, .25}, {0, .25, true, .25}, {3, 0, true, 0}} {
53- pass, ratio, diff, err := CompareGolden(changed, img, tc.tolerance, tc.max)
54- if err != nil || pass != tc.pass || ratio != tc.ratio || diff == nil {
55- t.Fatalf("%+v: %v %v %v", tc, pass, ratio, err)
56- }
57- }
58- alpha := image.NewNRGBA(img.Bounds())
59- for y := 5; y < 7; y++ {
60- for x := 4; x < 6; x++ {
61- alpha.SetNRGBA(x, y, c)
62- }
63- }
64- alpha.SetNRGBA(4, 5, color.NRGBA{51, 102, 153, 254})
65- pass, ratio, diff, err := CompareGolden(alpha, img, 0, 0)
66- if err != nil || pass || ratio != .25 || diff == nil || diff.NRGBAAt(4, 5) == (color.NRGBA{0, 0, 0, 255}) {
67- t.Fatalf("alpha diff: %v %v %v %v", pass, ratio, diff, err)
68- }
69- if _, _, _, err := CompareGolden(img, image.NewNRGBA(image.Rect(0, 0, 1, 1)), 0, 0); err == nil {
70- t.Fatal("different bounds accepted")
71- }
72- if _, _, _, err := CompareGolden(img, img, 0, 1.1); err == nil {
73- t.Fatal("invalid ratio accepted")
74- }
75- for _, s := range []string{"red", "#GG0000", "#12345"} {
76- if _, err := ParseColor(s); err == nil {
77- t.Fatalf("accepted %q", s)
78- }
79- }
80- dir := t.TempDir()
81- path := filepath.Join(dir, "img.png")
82- if err := SavePNG(path, img); err != nil {
83- t.Fatal(err)
84- }
85- decoded, err := LoadPNG(path)
86- if err != nil || decoded.Bounds().Dx() != img.Bounds().Dx() || decoded.Bounds().Dy() != img.Bounds().Dy() {
87- t.Fatalf("png: %v", err)
88- }
89-}
90-func TestConfig(t *testing.T) {
91- cfg, err := Config("800x600", "1.5", "fr", "#336699")
92- if err != nil {
93- t.Fatal(err)
94- }
95- for _, s := range []string{"output.HEADLESS-1 = 800x600", "output.HEADLESS-1.scale = 1.5", "keyboard.layout = fr", "background = #336699", "log.debug = wayland"} {
96- if !strings.Contains(cfg, s) {
97- t.Fatalf("missing %s", s)
98- }
99- }
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"}} {
108- if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil {
109- t.Fatalf("accepted %v", tc)
110- }
111- }
112-}
113-func TestDiscoverSocket(t *testing.T) {
114- dir := t.TempDir()
115- if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "" {
116- t.Fatalf("empty: %q %v", name, err)
117- }
118- if err := os.Mkdir(filepath.Join(dir, "neferwl"), 0700); err != nil {
119- t.Fatal(err)
120- }
121- if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-1.json"), []byte("{}"), 0600); err != nil {
122- t.Fatal(err)
123- }
124- if name, _ := DiscoverSocket(context.Background(), dir); name != "" {
125- t.Fatal("state without socket")
126- }
127- ln, err := net.Listen("unix", filepath.Join(dir, "wayland-1"))
128- if err != nil {
129- t.Fatal(err)
130- }
131- defer ln.Close()
132- if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "wayland-1" {
133- t.Fatalf("socket: %q %v", name, err)
134- }
135- if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-2.json"), []byte("{}"), 0600); err != nil {
136- t.Fatal(err)
137- }
138- ln2, err := net.Listen("unix", filepath.Join(dir, "wayland-2"))
139- if err != nil {
140- t.Fatal(err)
141- }
142- defer ln2.Close()
143- if _, err := DiscoverSocket(context.Background(), dir); err == nil {
144- t.Fatal("multiple sockets accepted")
145- }
146-}
147-func TestHeadlessBackground(t *testing.T) {
148- if os.Getenv("NEFERGUI_HARNESS") != "1" {
149- t.Skip("set NEFERGUI_HARNESS=1")
150- }
151- out := filepath.Join(t.TempDir(), "run")
152- res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1.5", Layout: "fr", Background: "#336699", Out: out, Timeout: 60 * time.Second, ReadyTimeout: 20 * time.Second, Expect: Expectations{Probes: []Probe{{Name: "background", X: 64, Y: 48, Color: "#336699", Tolerance: 1}}}})
153- t.Logf("code=%d result=%+v", code, res)
154- if code != Pass {
155- data, _ := os.ReadFile(filepath.Join(out, "logs", "compositor.log"))
156- t.Fatalf("run: %s\ncompositor:\n%s", res.Error, data)
157- }
158- if len(res.Checks) != 1 || !res.Checks[0].Pass {
159- t.Fatal(res.Checks)
160- }
161- if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
162- t.Fatal(err)
163- }
164-}
165-
166-func TestClientFailureAndCleanup(t *testing.T) {
167- if os.Getenv("NEFERGUI_HARNESS") != "1" {
168- t.Skip("set NEFERGUI_HARNESS=1")
169- }
170- isolateSessionTemp(t)
171- out := filepath.Join(t.TempDir(), "failure")
172- res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 10 * time.Second, ReadyTimeout: 5 * time.Second, Client: []string{"/bin/false"}})
173- if code != ProcessFailure || res.Status != "error" {
174- t.Fatalf("code=%d result=%+v", code, res)
175- }
176- if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
177- t.Fatal(err)
178- }
179- checkCleanup(t, res)
180-}
181-
182-func checkCleanup(t *testing.T, res Result) {
183- t.Helper()
184- if _, ok := res.Artifacts["session"]; ok {
185- t.Fatal("temporary session retained")
186- }
187- matches, err := filepath.Glob(filepath.Join(os.Getenv("TMPDIR"), "nefergui-harness-*"))
188- if err != nil || len(matches) != 0 {
189- t.Fatalf("temporary sessions remain: %v %v", matches, err)
190- }
191- if res.Compositor.PID <= 0 {
192- t.Fatalf("missing compositor pid: %+v", res)
193- }
194- if err := syscall.Kill(res.Compositor.PID, 0); err != syscall.ESRCH {
195- t.Fatalf("compositor pid %d not reaped: %v", res.Compositor.PID, err)
196- }
197-}
198-
199-func isolateSessionTemp(t *testing.T) { t.Helper(); t.Setenv("TMPDIR", t.TempDir()) }
200-
201-func TestCancelAndCleanup(t *testing.T) {
202- if os.Getenv("NEFERGUI_HARNESS") != "1" {
203- t.Skip("set NEFERGUI_HARNESS=1")
204- }
205- isolateSessionTemp(t)
206- ctx, cancel := context.WithCancel(context.Background())
207- defer cancel()
208- out := filepath.Join(t.TempDir(), "cancel")
209- ready := filepath.Join(t.TempDir(), "ready")
210- result := make(chan struct {
211- res Result
212- code int
213- }, 1)
214- go func() {
215- res, code := Run(ctx, Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 20 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{"sh", "-c", "touch \"$1\"; sleep 30", "sh", ready}})
216- result <- struct {
217- res Result
218- code int
219- }{res, code}
220- }()
221- deadline := time.After(12 * time.Second)
222- for {
223- if _, err := os.Stat(ready); err == nil {
224- break
225- }
226- select {
227- case <-deadline:
228- cancel()
229- t.Fatal("client never started")
230- case <-time.After(25 * time.Millisecond):
231- }
232- }
233- cancel()
234- r := <-result
235- if r.code != ProcessFailure || !strings.Contains(r.res.Error, "context canceled") {
236- t.Fatalf("cancel: %d %+v", r.code, r.res)
237- }
238- checkCleanup(t, r.res)
239- if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
240- t.Fatal(err)
241- }
242-}
243-
244-func TestClientEnvironment(t *testing.T) {
245- if os.Getenv("NEFERGUI_HARNESS") != "1" {
246- t.Skip("set NEFERGUI_HARNESS=1")
247- }
248- isolateSessionTemp(t)
249- t.Setenv("DISPLAY", ":99")
250- t.Setenv("WAYLAND_DISPLAY", "host-socket")
251- t.Setenv("WAYLAND_SOCKET", "42")
252- out := filepath.Join(t.TempDir(), "env")
253- envFile := filepath.Join(t.TempDir(), "client.env")
254- res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 20 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{"sh", "-c", "env > \"$1\"", "sh", envFile}})
255- if code != Pass {
256- t.Fatalf("run: %d %+v", code, res)
257- }
258- b, err := os.ReadFile(envFile)
259- if err != nil {
260- t.Fatal(err)
261- }
262- env := map[string]string{}
263- for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
264- k, v, ok := strings.Cut(line, "=")
265- if ok {
266- env[k] = v
267- }
268- }
269- for _, k := range []string{"DISPLAY", "WAYLAND_SOCKET"} {
270- if _, ok := env[k]; ok {
271- t.Fatalf("host %s leaked: %s", k, b)
272- }
273- }
274- if env["WAYLAND_DISPLAY"] == "" || env["WAYLAND_DISPLAY"] == "host-socket" {
275- t.Fatalf("wrong socket: %s", b)
276- }
277- if runtime := env["XDG_RUNTIME_DIR"]; !strings.HasPrefix(runtime, filepath.Join(os.TempDir(), "nefergui-harness-")) || filepath.Base(runtime) != "runtime" {
278- t.Fatalf("wrong runtime: %s", b)
279- }
280- checkCleanup(t, res)
281-}
282-func TestUnpinnedOverride(t *testing.T) {
283- t.Setenv("NEFERGUI_NEFERWL", "/bin/true")
284- options := Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: filepath.Join(t.TempDir(), "denied"), Timeout: time.Second, ReadyTimeout: 500 * time.Millisecond}
285- res, code := Run(context.Background(), options)
286- if code != Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" {
287- t.Fatalf("denied: %d %+v", code, res)
288- }
289- data, err := os.ReadFile(filepath.Join(options.Out, "result.json"))
290- if err != nil {
291- t.Fatal(err)
292- }
293- if !strings.Contains(string(data), `"pinned": false`) || !strings.Contains(string(data), `"binary": "/bin/true"`) {
294- t.Fatalf("result: %s", data)
295- }
296- options.Out = filepath.Join(t.TempDir(), "allowed")
297- options.AllowUnpinned = true
298- res, code = Run(context.Background(), options)
299- if code == Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" {
300- t.Fatalf("allowed: %d %+v", code, res)
301- }
302-}
303-
304-func TestResultWriteFailure(t *testing.T) {
305- out := filepath.Join(t.TempDir(), "run")
306- binary := filepath.Join(t.TempDir(), "fake-neferwl")
307- if err := os.WriteFile(binary, []byte("#!/bin/sh\nmkdir \""+filepath.Join(out, "result.json")+"\"\n"), 0700); err != nil {
308- t.Fatal(err)
309- }
310- t.Setenv("NEFERGUI_NEFERWL", binary)
311- res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, AllowUnpinned: true, Timeout: time.Second, ReadyTimeout: 500 * time.Millisecond})
312- if code != ProcessFailure || !strings.Contains(res.Error, "write result.json") {
313- t.Fatalf("write failure: %d %+v", code, res)
314- }
315-}
316-
317-func TestCopyRunLog(t *testing.T) {
318- dir := t.TempDir()
319- dst := filepath.Join(dir, "out.log")
320- // Older NeferWL writes no run log: no artifact, no error.
321- if ok, err := copyRunLog(filepath.Join(dir, "missing.log"), dst); ok || err != nil {
322- t.Fatalf("missing: %v %v", ok, err)
323- }
324- src := filepath.Join(dir, "latest.log")
325- if err := os.WriteFile(src, []byte("{}\n"), 0600); err != nil {
326- t.Fatal(err)
327- }
328- if ok, err := copyRunLog(src, dst); !ok || err != nil {
329- t.Fatalf("copy: %v %v", ok, err)
330- }
331- if data, err := os.ReadFile(dst); err != nil || string(data) != "{}\n" {
332- t.Fatalf("copied %q %v", data, err)
333- }
334- // A failed write is reported, not hidden.
335- if ok, err := copyRunLog(src, filepath.Join(dir, "no-such-dir", "out.log")); ok || err == nil {
336- t.Fatalf("write failure: %v %v", ok, err)
337- }
338-}
D internal/harness/run.go +0 -570
@@ -1,570 +0,0 @@
1-package harness
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "io/fs"
9- "os"
10- "os/exec"
11- "path/filepath"
12- "strconv"
13- "strings"
14- "syscall"
15- "time"
16-)
17-
18-const (
19- Pass = 0
20- Failed = 1
21- Usage = 2
22- Unavailable = 3
23- ProcessFailure = 4
24-)
25-
26-type Options struct {
27- Size string
28- Scale string
29- Layout string
30- Background string
31- Input string
32- Out string
33- Keep bool
34- AllowUnpinned bool
35- Timeout time.Duration
36- ReadyTimeout time.Duration
37- Client []string
38- Expect Expectations
39- GoldenBase string
40-}
41-type Compositor struct {
42- Pinned bool `json:"pinned"`
43- SHA string `json:"sha,omitempty"`
44- Binary string `json:"binary,omitempty"`
45- PID int `json:"pid,omitempty"`
46-}
47-type Result struct {
48- Compositor Compositor `json:"compositor"`
49- Schema string `json:"schema"`
50- Status string `json:"status"`
51- Checks []Check `json:"checks"`
52- Artifacts map[string]string `json:"artifacts"`
53- Timings map[string]string `json:"timings"`
54- Error string `json:"error,omitempty"`
55-}
56-
57-func validate(o Options) error {
58- if _, err := Config(o.Size, o.Scale, o.Layout, o.Background); err != nil {
59- return err
60- }
61- if o.Out == "" || o.Timeout <= 0 || o.ReadyTimeout <= 0 || o.ReadyTimeout >= o.Timeout {
62- return fmt.Errorf("out and positive timeouts (ready < total) required")
63- }
64- if o.Input != "" {
65- if fi, err := os.Stat(o.Input); err != nil || !fi.Mode().IsRegular() {
66- return fmt.Errorf("input script not readable: %s", o.Input)
67- }
68- }
69- for _, p := range o.Expect.Probes {
70- if _, err := ParseColor(p.Color); err != nil {
71- return err
72- }
73- }
74- for _, r := range o.Expect.Regions {
75- if r.Color != "" {
76- if _, err := ParseColor(r.Color); err != nil {
77- return err
78- }
79- }
80- }
81- for _, g := range o.Expect.Goldens {
82- if g.MaxDiffRatio < 0 || g.MaxDiffRatio > 1 || g.Path == "" {
83- return fmt.Errorf("invalid golden %q", g.Name)
84- }
85- }
86- return nil
87-}
88-
89-// copyRunLog copies the compositor's structured run log into the artifacts.
90-// A missing log (older NeferWL, early failure) is not an error; it reports
91-// whether dst was written.
92-func copyRunLog(src, dst string) (bool, error) {
93- data, err := os.ReadFile(src)
94- if errors.Is(err, fs.ErrNotExist) {
95- return false, nil
96- }
97- if err == nil {
98- err = os.WriteFile(dst, data, 0600)
99- }
100- return err == nil, err
101-}
102-func newLog(path string) (*os.File, error) {
103- return os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
104-}
105-func waitSocket(ctx context.Context, runtime string, exited <-chan error) (string, error) {
106- ticker := time.NewTicker(25 * time.Millisecond)
107- defer ticker.Stop()
108- for {
109- name, err := DiscoverSocket(ctx, runtime)
110- if err != nil || name != "" {
111- if ctx.Err() != nil {
112- return "", ctx.Err()
113- }
114- return name, err
115- }
116- select {
117- case err := <-exited:
118- return "", fmt.Errorf("compositor exited before ready: %v", err)
119- case <-ctx.Done():
120- return "", ctx.Err()
121- case <-ticker.C:
122- }
123- }
124-}
125-
126-// Run writes result.json for every run that has acquired an artifact directory.
127-// It never owns client-side debug files: the client receives NEFERGUI_DEBUG_DIR and writes them.
128-func Run(ctx context.Context, o Options) (res Result, code int) {
129- res = Result{Schema: "nefergui-harness/v1", Status: "error", Checks: []Check{}, Artifacts: map[string]string{}, Timings: map[string]string{}}
130- code = ProcessFailure
131- if err := validate(o); err != nil {
132- res.Error = err.Error()
133- return res, Usage
134- }
135- start := time.Now()
136- runCtx, cancel := context.WithTimeout(ctx, o.Timeout)
137- defer cancel()
138- out, err := filepath.Abs(o.Out)
139- if err != nil {
140- res.Error = err.Error()
141- return res, Usage
142- }
143- if err = os.Mkdir(out, 0700); err != nil {
144- res.Error = fmt.Sprintf("out must be a new directory: %v", err)
145- return res, Usage
146- }
147- res.Artifacts["root"] = out
148- defer func() {
149- res.Timings["total"] = time.Since(start).String()
150- data, e := json.MarshalIndent(res, "", " ")
151- if e == nil {
152- e = os.WriteFile(filepath.Join(out, "result.json"), append(data, '\n'), 0600)
153- }
154- if e != nil {
155- writeErr := fmt.Errorf("write result.json: %w", e)
156- if res.Error != "" {
157- writeErr = errors.Join(errors.New(res.Error), writeErr)
158- }
159- res.Error = writeErr.Error()
160- res.Status = "error"
161- code = ProcessFailure
162- }
163- }()
164- fail := func(err error, c int) (Result, int) { res.Error = err.Error(); return res, c }
165- for key, dir := range map[string]string{"frames": "frames", "debug": "client-debug", "logs": "logs"} {
166- path := filepath.Join(out, dir)
167- if err = os.Mkdir(path, 0700); err != nil {
168- return fail(err, ProcessFailure)
169- }
170- res.Artifacts[key] = path
171- }
172- shots := FramesDir(filepath.Join(out, "frames"), o.Size) // first output's screenshots
173- res.Artifacts["frame"] = filepath.Join(shots, "latest.png")
174- res.Artifacts["compositor_log"] = filepath.Join(out, "logs", "compositor.log")
175- res.Artifacts["client_stdout"] = filepath.Join(out, "logs", "client.stdout")
176- res.Artifacts["client_stderr"] = filepath.Join(out, "logs", "client.stderr")
177- session, err := os.MkdirTemp("", "nefergui-harness-")
178- if err != nil {
179- return fail(err, ProcessFailure)
180- }
181- if !o.Keep {
182- defer os.RemoveAll(session)
183- } else {
184- res.Artifacts["session"] = session
185- }
186- runtime := filepath.Join(session, "runtime")
187- configHome := filepath.Join(session, "config")
188- for _, d := range []string{runtime, filepath.Join(configHome, "neferwl")} {
189- if err = os.MkdirAll(d, 0700); err != nil {
190- return fail(err, ProcessFailure)
191- }
192- }
193- cfg, err := Config(o.Size, o.Scale, o.Layout, o.Background)
194- if err != nil {
195- return fail(err, Usage)
196- }
197- if err = os.WriteFile(filepath.Join(configHome, "neferwl", "config"), []byte(cfg), 0600); err != nil {
198- return fail(err, ProcessFailure)
199- }
200- buildStart := time.Now()
201- if path := os.Getenv("NEFERGUI_NEFERWL"); path != "" {
202- res.Compositor = Compositor{Binary: path}
203- if !o.AllowUnpinned {
204- return fail(fmt.Errorf("NEFERGUI_NEFERWL requires --allow-unpinned"), Usage)
205- }
206- } else {
207- res.Compositor = Compositor{Pinned: true, SHA: NeferWLSHA}
208- }
209- buildCtx, buildCancel := context.WithTimeout(runCtx, 3*time.Minute)
210- binary, err := NeferWL(buildCtx)
211- buildCancel()
212- res.Timings["build"] = time.Since(buildStart).String()
213- if err != nil {
214- return fail(err, Unavailable)
215- }
216- if err = runCtx.Err(); err != nil {
217- return fail(err, Unavailable)
218- }
219- logFile, err := newLog(res.Artifacts["compositor_log"])
220- if err != nil {
221- return fail(err, ProcessFailure)
222- }
223- defer logFile.Close()
224- args := []string{"--backend=headless", "--no-terminal", "--no-xwayland", "--screenshot", res.Artifacts["frames"], "--size", o.Size, "--timeout", o.Timeout.String()}
225- if o.Input != "" {
226- args = append(args, "--input", o.Input)
227- }
228- cmd := exec.Command(binary, args...)
229- // NeferWL writes structured run logs under XDG_STATE_HOME; keep them in
230- // the session instead of the host state directory, and copy them out.
231- stateHome := filepath.Join(session, "state")
232- cmd.Env = withEnv(isolatedEnv(os.Environ()), map[string]string{"XDG_RUNTIME_DIR": runtime, "XDG_CONFIG_HOME": configHome, "XDG_STATE_HOME": stateHome, "WAYLAND_DISPLAY": ""})
233- // Registered before the reap defer, so it runs after the compositor exits.
234- defer func() {
235- dst := filepath.Join(out, "logs", "compositor-run.log")
236- ok, e := copyRunLog(filepath.Join(stateHome, "neferwl", "runs", "headless", "latest.log"), dst)
237- if ok {
238- res.Artifacts["compositor_run_log"] = dst
239- }
240- if e != nil {
241- copyErr := fmt.Errorf("copy compositor run log: %w", e)
242- if res.Error != "" {
243- copyErr = errors.Join(errors.New(res.Error), copyErr)
244- }
245- res.Error, res.Status, code = copyErr.Error(), "error", ProcessFailure
246- }
247- }()
248- cmd.Stdout = logFile
249- cmd.Stderr = logFile
250- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
251- if err = cmd.Start(); err != nil {
252- return fail(err, Unavailable)
253- }
254- res.Compositor.PID = cmd.Process.Pid
255- exited := make(chan error, 1)
256- go func() { exited <- cmd.Wait(); close(exited) }()
257- // Always reap the process; killing the group also stops any children.
258- defer func() {
259- _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
260- <-exited
261- }()
262- readyCtx, readyCancel := context.WithTimeout(runCtx, o.ReadyTimeout)
263- defer readyCancel()
264- readyStart := time.Now()
265- socket, err := waitSocket(readyCtx, runtime, exited)
266- res.Timings["ready"] = time.Since(readyStart).String()
267- if err != nil {
268- return fail(err, Unavailable)
269- }
270- // Create empty logs even for compositor-only sessions.
271- for _, path := range []string{res.Artifacts["client_stdout"], res.Artifacts["client_stderr"]} {
272- f, e := newLog(path)
273- if e != nil {
274- return fail(e, ProcessFailure)
275- }
276- _ = f.Close()
277- }
278- env := withEnv(isolatedEnv(os.Environ()), map[string]string{"XDG_RUNTIME_DIR": runtime, "XDG_CONFIG_HOME": configHome, "WAYLAND_DISPLAY": socket, "NEFERGUI_DEBUG_DIR": res.Artifacts["debug"]})
279- if len(o.Client) > 0 {
280- stdout, e := os.OpenFile(res.Artifacts["client_stdout"], os.O_WRONLY, 0600)
281- if e != nil {
282- return fail(e, ProcessFailure)
283- }
284- defer stdout.Close()
285- stderr, e := os.OpenFile(res.Artifacts["client_stderr"], os.O_WRONLY, 0600)
286- if e != nil {
287- return fail(e, ProcessFailure)
288- }
289- defer stderr.Close()
290- client := exec.Command(o.Client[0], o.Client[1:]...)
291- client.Env = env
292- client.Stdout = stdout
293- client.Stderr = stderr
294- client.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
295- if err = client.Start(); err != nil {
296- return fail(err, ProcessFailure)
297- }
298- clientExit := make(chan error, 1)
299- go func() { clientExit <- client.Wait(); close(clientExit) }()
300- defer func() {
301- _ = syscall.Kill(-client.Process.Pid, syscall.SIGKILL)
302- <-clientExit
303- }()
304- clientStart := time.Now()
305- capturePath := filepath.Join(res.Artifacts["frames"], "capture.png")
306- requestPath := filepath.Join(res.Artifacts["debug"], "capture-request")
307- requestSeen := false
308- var requestedAt time.Time
309- captureTick := time.NewTicker(10 * time.Millisecond)
310- defer captureTick.Stop()
311- clientLoop:
312- for {
313- select {
314- case err = <-clientExit:
315- break clientLoop
316- case err = <-exited:
317- err = fmt.Errorf("compositor exited during client: %v", err)
318- break clientLoop
319- case <-runCtx.Done():
320- err = runCtx.Err()
321- break clientLoop
322- case <-captureTick.C:
323- if !requestSeen {
324- requestedAt, err = captureRequestTime(requestPath)
325- if errors.Is(err, os.ErrNotExist) {
326- err = nil
327- } else if err == nil {
328- requestSeen = true
329- } else {
330- break clientLoop
331- }
332- }
333- if requestSeen {
334- err = copyNextFrame(shots, requestedAt, capturePath)
335- if err == nil {
336- res.Artifacts["capture"] = capturePath
337- requestSeen = false
338- break clientLoop
339- }
340- if !errors.Is(err, os.ErrNotExist) {
341- break clientLoop
342- }
343- err = nil
344- }
345- }
346- }
347- // After capture, still wait for the client to finish normally.
348- if res.Artifacts["capture"] != "" && err == nil {
349- select {
350- case err = <-clientExit:
351- case err = <-exited:
352- err = fmt.Errorf("compositor exited during client: %v", err)
353- case <-runCtx.Done():
354- err = runCtx.Err()
355- }
356- }
357- res.Timings["client"] = time.Since(clientStart).String()
358- // A request near client exit must not silently fall back to latest.png.
359- if err == nil && res.Artifacts["capture"] == "" {
360- if !requestSeen {
361- requestedAt, err = captureRequestTime(requestPath)
362- if errors.Is(err, os.ErrNotExist) {
363- err = nil
364- } else if err == nil {
365- requestSeen = true
366- }
367- }
368- if requestSeen && err == nil {
369- for runCtx.Err() == nil {
370- err = copyNextFrame(shots, requestedAt, capturePath)
371- if err == nil {
372- res.Artifacts["capture"] = capturePath
373- break
374- }
375- if !errors.Is(err, os.ErrNotExist) {
376- break
377- }
378- err = nil
379- select {
380- case <-runCtx.Done():
381- case <-captureTick.C:
382- }
383- }
384- if res.Artifacts["capture"] == "" && err == nil {
385- err = fmt.Errorf("capture-request: %w", runCtx.Err())
386- }
387- }
388- }
389- if err != nil {
390- return fail(fmt.Errorf("client/compositor: %w", err), ProcessFailure)
391- }
392- select {
393- case e := <-exited:
394- return fail(fmt.Errorf("compositor exited during client: %v", e), ProcessFailure)
395- default:
396- }
397- }
398- // Wait briefly for the asynchronous screenshot writer after the client exits.
399- frameCtx, frameCancel := context.WithTimeout(runCtx, 3*time.Second)
400- defer frameCancel()
401- // A compositor crash after readiness is not a successful capture, even if latest.png exists.
402- select {
403- case e := <-exited:
404- return fail(fmt.Errorf("compositor exited before capture: %v", e), ProcessFailure)
405- default:
406- }
407- tick := time.NewTicker(25 * time.Millisecond)
408- defer tick.Stop()
409- for {
410- _, e := LoadPNG(res.Artifacts["frame"])
411- if e == nil {
412- break
413- }
414- select {
415- case <-frameCtx.Done():
416- return fail(fmt.Errorf("no frame: %w", frameCtx.Err()), ProcessFailure)
417- case e := <-exited:
418- return fail(fmt.Errorf("compositor exited before frame: %v", e), ProcessFailure)
419- case <-tick.C:
420- }
421- }
422- checkFrame := res.Artifacts["frame"]
423- if res.Artifacts["capture"] != "" {
424- checkFrame = res.Artifacts["capture"]
425- }
426- checks, err := Evaluate(checkFrame, o.Expect, o.GoldenBase, out)
427- if err != nil {
428- return fail(err, ProcessFailure)
429- }
430- res.Checks = checks
431- for _, c := range checks {
432- if !c.Pass {
433- res.Status = "failed"
434- return res, Failed
435- }
436- }
437- res.Status = "pass"
438- return res, Pass
439-}
440-
441-// frameIndex accepts only compositor screenshots, not latest.png or temporary files.
442-func frameIndex(name string) (int, bool) {
443- if !strings.HasPrefix(name, "frame-") || !strings.HasSuffix(name, ".png") {
444- return 0, false
445- }
446- digits := strings.TrimSuffix(strings.TrimPrefix(name, "frame-"), ".png")
447- if len(digits) != 6 {
448- return 0, false
449- }
450- for _, c := range digits {
451- if c < '0' || c > '9' {
452- return 0, false
453- }
454- }
455- n, err := strconv.Atoi(digits)
456- return n, err == nil
457-}
458-func captureRequestTime(path string) (time.Time, error) {
459- data, err := os.ReadFile(path)
460- if err != nil {
461- return time.Time{}, err
462- }
463- ns, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
464- if err != nil || ns <= 0 {
465- return time.Time{}, fmt.Errorf("invalid capture-request timestamp %q", strings.TrimSpace(string(data)))
466- }
467- return time.Unix(0, ns), nil
468-}
469-
470-// nextFrame picks the second screenshot written after the commit token.
471-// NeferWL renders and writes screenshots one after another, so the first file
472-// finished after the token may have been rendered before it; the second began
473-// after the first finished and therefore shows the committed frame.
474-// An incomplete candidate is retried instead of selecting a later frame.
475-func nextFrame(dir string, after time.Time) (string, error) {
476- entries, err := os.ReadDir(dir)
477- if err != nil {
478- return "", err
479- }
480- first, second := int(^uint(0)>>1), int(^uint(0)>>1)
481- names := map[int]string{}
482- for _, entry := range entries {
483- if entry.IsDir() {
484- continue
485- }
486- n, ok := frameIndex(entry.Name())
487- if !ok {
488- continue
489- }
490- info, e := entry.Info()
491- if e != nil {
492- return "", e
493- }
494- if !info.ModTime().After(after) {
495- continue
496- }
497- names[n] = entry.Name()
498- if n < first {
499- first, second = n, first
500- } else if n < second {
501- second = n
502- }
503- }
504- return names[second], nil
505-}
506-func copyNextFrame(dir string, after time.Time, destination string) error {
507- name, err := nextFrame(dir, after)
508- if err != nil {
509- return err
510- }
511- if name == "" {
512- return os.ErrNotExist
513- }
514- // NeferWL writes frame-N directly. Retry an incomplete PNG on the next tick.
515- path := filepath.Join(dir, name)
516- if _, err := LoadPNG(path); err != nil {
517- return os.ErrNotExist
518- }
519- data, err := os.ReadFile(path)
520- if err != nil {
521- return err
522- }
523- return os.WriteFile(destination, data, 0600)
524-}
525-func Evaluate(frame string, expect Expectations, base, out string) ([]Check, error) {
526- img, err := LoadPNG(frame)
527- if err != nil {
528- return nil, err
529- }
530- checks := make([]Check, 0, len(expect.Probes)+len(expect.Regions)+len(expect.Goldens))
531- add := func(name string, err error) {
532- c := Check{Name: name, Pass: err == nil}
533- if err != nil {
534- c.Detail = err.Error()
535- }
536- checks = append(checks, c)
537- }
538- for _, p := range expect.Probes {
539- want, _ := ParseColor(p.Color)
540- add(p.Name, PixelProbe(img, p.X, p.Y, want, p.Tolerance))
541- }
542- for _, r := range expect.Regions {
543- err := BoundsCheck(img, r.Rect)
544- if err == nil && r.Color != "" {
545- want, _ := ParseColor(r.Color)
546- err = UniformRegion(img, r.Rect, want, r.Tolerance)
547- }
548- add(r.Name, err)
549- }
550- for i, g := range expect.Goldens {
551- path := g.Path
552- if !filepath.IsAbs(path) {
553- path = filepath.Join(base, path)
554- }
555- ref, e := LoadPNG(path)
556- if e != nil {
557- add(g.Name, e)
558- continue
559- }
560- pass, ratio, diff, e := CompareGolden(img, ref, g.Tolerance, g.MaxDiffRatio)
561- if diff != nil {
562- e = errors.Join(e, SavePNG(filepath.Join(out, fmt.Sprintf("diff-%d.png", i)), diff))
563- }
564- if e == nil && !pass {
565- e = fmt.Errorf("differing pixel ratio %.6f exceeds %.6f", ratio, g.MaxDiffRatio)
566- }
567- add(g.Name, e)
568- }
569- return checks, nil
570-}
D internal/keyboard/keyboard.go +0 -272
@@ -1,272 +0,0 @@
1-// Package keyboard interprets Wayland evdev key events on the UI loop.
2-package keyboard
3-
4-import (
5- "errors"
6- "os"
7- "time"
8-
9- xkb "github.com/bnema/purego-xkbcommon"
10- "github.com/bnema/purego-xkbcommon/raw"
11-)
12-
13-type Key struct {
14- Physical uint32
15- Logical uint32
16- Name, Text string
17- Pressed, Repeat bool
18- Shift, Ctrl bool
19- Group uint32
20-}
21-type Keyboard struct {
22- context *xkb.Context
23- keymap *xkb.Keymap
24- state *xkb.State
25- compose *xkb.ComposeState
26- held map[uint32]Key
27- secretHeld map[uint32]SecretKey
28- secretMode bool
29- rate int
30- delay time.Duration
31- next time.Time
32- repeating uint32
33- focus bool
34- composePending bool
35-}
36-
37-func New(locale string) (*Keyboard, error) {
38- c, err := xkb.NewContext()
39- if err != nil {
40- return nil, err
41- }
42- k := &Keyboard{context: c, held: make(map[uint32]Key), focus: true}
43- if locale != "" {
44- t, e := c.NewComposeTable(locale)
45- if e != nil {
46- c.Close()
47- return nil, e
48- }
49- k.compose, e = t.NewState()
50- t.Close()
51- if e != nil {
52- c.Close()
53- return nil, e
54- }
55- }
56- return k, nil
57-}
58-func (k *Keyboard) Close() {
59- k.Focus(false)
60- if k.compose != nil {
61- k.compose.Close()
62- }
63- if k.state != nil {
64- k.state.Close()
65- }
66- if k.keymap != nil {
67- k.keymap.Close()
68- }
69- if k.context != nil {
70- k.context.Close()
71- }
72-}
73-
74-// ReplaceFD consumes the received FD even on failure; the previous keymap remains valid on failure.
75-func (k *Keyboard) ReplaceFD(fd, size int) error {
76- if fd < 0 {
77- return errors.New("keyboard: invalid fd")
78- }
79- file := os.NewFile(uintptr(fd), "keymap")
80- if file == nil {
81- return errors.New("keyboard: invalid fd")
82- }
83- defer file.Close()
84- km, err := k.context.NewKeymapFD(fd, size)
85- if err != nil {
86- return err
87- }
88- return k.replace(km)
89-}
90-func (k *Keyboard) Rules(layout, options string) error {
91- km, err := k.context.NewKeymapRules("evdev", "pc105", layout, "", options)
92- if err != nil {
93- return err
94- }
95- return k.replace(km)
96-}
97-func (k *Keyboard) replace(km *xkb.Keymap) error {
98- s, err := km.NewState()
99- if err != nil {
100- km.Close()
101- return err
102- }
103- k.Focus(false)
104- if k.state != nil {
105- k.state.Close()
106- }
107- if k.keymap != nil {
108- k.keymap.Close()
109- }
110- k.keymap = km
111- k.state = s
112- k.Focus(true)
113- return nil
114-}
115-func (k *Keyboard) Mask(depressed, latched, locked, groupDepressed, groupLatched, groupLocked uint32) error {
116- if k.state == nil {
117- return errors.New("keyboard: no keymap")
118- }
119- _, err := k.state.UpdateMask(depressed, latched, locked, groupDepressed, groupLatched, groupLocked)
120- return err
121-}
122-func (k *Keyboard) Focus(on bool) {
123- k.focus = on
124- k.held = make(map[uint32]Key)
125- clear(k.secretHeld)
126- k.secretMode = false
127- k.repeating = 0
128- k.next = time.Time{}
129- if k.compose != nil {
130- k.compose.Reset()
131- }
132- k.composePending = false
133-}
134-func (k *Keyboard) RepeatInfo(rate int, delay time.Duration) {
135- k.rate = rate
136- k.delay = delay
137- if rate <= 0 {
138- k.repeating = 0
139- }
140-}
141-func (k *Keyboard) Event(evdev uint32, pressed bool, now time.Time) (Key, error) {
142- if k.secretMode {
143- k.Focus(k.focus)
144- }
145- if !pressed {
146- key := k.held[evdev]
147- delete(k.held, evdev)
148- if k.repeating == evdev {
149- k.repeating = 0
150- }
151- key.Pressed = false
152- key.Text = ""
153- return key, nil
154- }
155- if !k.focus || k.state == nil {
156- return Key{}, nil
157- }
158- code := xkb.WaylandKeycode(evdev)
159- sym, err := k.state.KeySym(code)
160- if err != nil {
161- return Key{}, err
162- }
163- text, err := k.state.UTF8(code)
164- if err != nil {
165- return Key{}, err
166- }
167- name, _ := xkb.KeysymName(sym)
168- group, _ := k.state.Layout()
169- key := Key{Physical: evdev, Logical: sym, Name: name, Text: text, Pressed: true, Group: group}
170- if key.Shift, key.Ctrl, err = k.modifiers(); err != nil {
171- return Key{}, err
172- }
173- if k.compose != nil {
174- if _, err = k.compose.Feed(sym); err != nil {
175- return Key{}, err
176- }
177- status, _ := k.compose.Status()
178- switch status {
179- case raw.XKB_COMPOSE_COMPOSING, raw.XKB_COMPOSE_CANCELLED:
180- key.Text = ""
181- k.composePending = status == raw.XKB_COMPOSE_COMPOSING
182- if status == raw.XKB_COMPOSE_CANCELLED {
183- k.compose.Reset()
184- }
185- case raw.XKB_COMPOSE_COMPOSED:
186- key.Text, err = k.compose.UTF8()
187- k.compose.Reset()
188- k.composePending = false
189- if err != nil {
190- return Key{}, err
191- }
192- default:
193- k.composePending = false
194- }
195- }
196- k.held[evdev] = key
197- if k.rate > 0 && repeatable(sym) {
198- k.repeating = evdev
199- k.next = now.Add(k.delay)
200- }
201- return key, nil
202-}
203-
204-// Modifier, dead and compose keys never repeat. The compositor provides rate/delay;
205-// its keymap is authoritative for text and layout.
206-func repeatable(sym uint32) bool {
207- switch sym {
208- case raw.XKB_KEY_Shift_L, raw.XKB_KEY_Shift_R, raw.XKB_KEY_Control_L, raw.XKB_KEY_Control_R,
209- raw.XKB_KEY_Alt_L, raw.XKB_KEY_Alt_R, raw.XKB_KEY_Super_L, raw.XKB_KEY_Super_R,
210- raw.XKB_KEY_Caps_Lock, raw.XKB_KEY_Num_Lock, raw.XKB_KEY_Multi_key:
211- return false
212- }
213- return sym < raw.XKB_KEY_dead_grave || sym > raw.XKB_KEY_dead_currency
214-}
215-
216-// Tick returns at most one repeat for a due deadline, without accumulating missed ticks.
217-// It resolves the held physical key against the current modifier/group mask.
218-// Compose is fed only by physical presses: synthetic repeats neither advance a
219-// compose sequence nor replay the press-time composed text.
220-func (k *Keyboard) Tick(now time.Time) (Key, bool) {
221- if k.secretMode || !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) {
222- return Key{}, false
223- }
224- physical := k.repeating
225- if _, ok := k.held[physical]; !ok {
226- return Key{}, false
227- }
228- code := xkb.WaylandKeycode(physical)
229- sym, err := k.state.KeySym(code)
230- if err != nil || !repeatable(sym) {
231- k.repeating = 0
232- return Key{}, false
233- }
234- text, err := k.state.UTF8(code)
235- if err != nil {
236- k.repeating = 0
237- return Key{}, false
238- }
239- group, err := k.state.Layout()
240- if err != nil {
241- k.repeating = 0
242- return Key{}, false
243- }
244- name, _ := xkb.KeysymName(sym)
245- if k.composePending {
246- text = ""
247- }
248- key := Key{Physical: physical, Logical: sym, Name: name, Text: text, Pressed: true, Repeat: true, Group: group}
249- // Repeats carry the current modifiers so e.g. held Shift+arrow keeps extending.
250- if key.Shift, key.Ctrl, err = k.modifiers(); err != nil {
251- k.repeating = 0
252- return Key{}, false
253- }
254- k.next = now.Add(time.Second / time.Duration(k.rate))
255- return key, true
256-}
257-
258-// modifiers reports whether Shift and Control are active in the current state.
259-func (k *Keyboard) modifiers() (shift, ctrl bool, err error) {
260- mods, err := k.state.Mods()
261- if err != nil {
262- return false, false, err
263- }
264- if idx, e := k.keymap.ModIndex("Shift"); e == nil && idx < 32 {
265- shift = mods&(1<<idx) != 0
266- }
267- if idx, e := k.keymap.ModIndex("Control"); e == nil && idx < 32 {
268- ctrl = mods&(1<<idx) != 0
269- }
270- return shift, ctrl, nil
271-}
272-func (k *Keyboard) NextRepeat() (time.Time, bool) { return k.next, k.repeating != 0 }
D internal/keyboard/keyboard_test.go +0 -240
@@ -1,240 +0,0 @@
1-package keyboard
2-
3-import (
4- "errors"
5- "os"
6- "testing"
7- "time"
8-
9- "github.com/bnema/purego-xkbcommon/raw"
10- "golang.org/x/sys/unix"
11-)
12-
13-func TestLayoutsAndRepeat(t *testing.T) {
14- k := requiredKeyboard(t, "en_US.UTF-8")
15- defer k.Close()
16- now := time.Now()
17- for _, tc := range []struct {
18- layout string
19- code uint32
20- want string
21- }{{"us", 16, "q"}, {"fr", 16, "a"}, {"de", 21, "z"}} {
22- if err := k.Rules(tc.layout, ""); err != nil {
23- t.Fatal(err)
24- }
25- key, err := k.Event(tc.code, true, now)
26- if err != nil || key.Text != tc.want || key.Physical != tc.code || key.Logical == 0 {
27- t.Fatalf("%s: %+v %v", tc.layout, key, err)
28- }
29- k.Event(tc.code, false, now)
30- }
31- if err := k.Rules("de", ""); err != nil {
32- t.Fatal(err)
33- }
34- idx, err := k.keymap.ModIndex("Mod5")
35- if err != nil {
36- t.Fatal(err)
37- }
38- if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil {
39- t.Fatal(err)
40- }
41- key, err := k.Event(18, true, now)
42- if err != nil || key.Text != "€" {
43- t.Fatalf("AltGr: %+v %v", key, err)
44- }
45- k.Event(18, false, now)
46- k.Mask(0, 0, 0, 0, 0, 0)
47- k.RepeatInfo(20, 300*time.Millisecond)
48- k.Event(16, true, now)
49- if _, ok := k.Tick(now.Add(299 * time.Millisecond)); ok {
50- t.Fatal("early repeat")
51- }
52- if _, ok := k.Tick(now.Add(300 * time.Millisecond)); !ok {
53- t.Fatal("missing repeat")
54- }
55- k.Focus(false)
56- if _, ok := k.Tick(now.Add(time.Second)); ok {
57- t.Fatal("repeat after focus loss")
58- }
59- k.Focus(true)
60- k.Event(16, true, now)
61- k.Event(16, false, now)
62- if _, ok := k.Tick(now.Add(time.Second)); ok {
63- t.Fatal("repeat after release")
64- }
65-}
66-func TestComposeAndGroups(t *testing.T) {
67- k := requiredKeyboard(t, "en_US.UTF-8")
68- var err error
69- defer k.Close()
70- if err = k.Rules("de", ""); err != nil {
71- t.Fatal(err)
72- }
73- now := time.Now()
74- dead, err := k.Event(41, true, now)
75- if err != nil || dead.Logical != raw.XKB_KEY_dead_circumflex || dead.Text != "" {
76- t.Fatalf("dead: %+v %v", dead, err)
77- }
78- k.Event(41, false, now)
79- composed, err := k.Event(18, true, now)
80- if err != nil || composed.Text != "ê" {
81- t.Fatalf("compose: %+v %v", composed, err)
82- }
83- if err = k.Rules("us,fr", ""); err != nil {
84- t.Fatal(err)
85- }
86- if err = k.Mask(0, 0, 0, 0, 0, 1); err != nil {
87- t.Fatal(err)
88- }
89- grouped, err := k.Event(16, true, now)
90- if err != nil || grouped.Text != "a" || grouped.Group != 1 {
91- t.Fatalf("group: %+v %v", grouped, err)
92- }
93-}
94-
95-func TestKeymapFDReplacement(t *testing.T) {
96- k := requiredKeyboard(t, "")
97- var err error
98- defer k.Close()
99- if err = k.Rules("us", ""); err != nil {
100- t.Fatal(err)
101- }
102- // A malformed regular file must be consumed without discarding the old map.
103- f, err := os.CreateTemp(t.TempDir(), "bad-keymap")
104- if err != nil {
105- t.Fatal(err)
106- }
107- defer f.Close()
108- if _, err = f.WriteString("bad keymap"); err != nil {
109- t.Fatal(err)
110- }
111- // Only transfer a raw duplicate. The original os.File remains its sole
112- // owner's responsibility, so its finalizer cannot close a reused FD.
113- fd, err := unix.Dup(int(f.Fd()))
114- if err != nil {
115- t.Fatal(err)
116- }
117- if err = k.ReplaceFD(fd, len("bad keymap")); err == nil {
118- t.Fatal("bad keymap accepted")
119- }
120- var stat unix.Stat_t
121- if err = unix.Fstat(fd, &stat); !errors.Is(err, unix.EBADF) {
122- t.Fatalf("keymap duplicate not consumed: %v", err)
123- }
124- if err = k.ReplaceFD(-1, 0); err == nil {
125- t.Fatal("invalid fd accepted")
126- }
127- key, err := k.Event(16, true, time.Now())
128- if err != nil || key.Text != "q" {
129- t.Fatalf("old map lost: %+v %v", key, err)
130- }
131- k.Event(16, false, time.Now())
132-}
133-
134-func TestValidFDReplacementCancelsRepeat(t *testing.T) {
135- k := requiredKeyboard(t, "")
136- defer k.Close()
137- if err := k.Rules("us", ""); err != nil {
138- t.Fatal(err)
139- }
140- now := time.Now()
141- k.RepeatInfo(20, 100*time.Millisecond)
142- press, err := k.Event(16, true, now)
143- if err != nil || press.Text != "q" {
144- t.Fatalf("old map press: %+v %v", press, err)
145- }
146- data, err := os.ReadFile("testdata/fr.xkb")
147- if err != nil {
148- t.Fatal(err)
149- }
150- file, err := os.CreateTemp(t.TempDir(), "keymap")
151- if err != nil {
152- t.Fatal(err)
153- }
154- defer file.Close()
155- if _, err = file.Write(append(data, 0)); err != nil {
156- t.Fatal(err)
157- }
158- fd, err := unix.Dup(int(file.Fd()))
159- if err != nil {
160- t.Fatal(err)
161- }
162- if err = k.ReplaceFD(fd, len(data)+1); err != nil {
163- t.Fatal(err)
164- }
165- var stat unix.Stat_t
166- if err = unix.Fstat(fd, &stat); !errors.Is(err, unix.EBADF) {
167- t.Fatalf("keymap duplicate not consumed: %v", err)
168- }
169- if _, ok := k.Tick(now.Add(time.Second)); ok {
170- t.Fatal("repeat survived keymap replacement")
171- }
172- if _, ok := k.NextRepeat(); ok {
173- t.Fatal("repeat deadline survived replacement")
174- }
175- key, err := k.Event(16, true, now)
176- if err != nil || key.Text != "a" {
177- t.Fatalf("new map press: %+v %v", key, err)
178- }
179-}
180-
181-// Required offline gate: only explicitly opted-out hosts may skip libxkbcommon.
182-func requiredKeyboard(t *testing.T, locale string) *Keyboard {
183- t.Helper()
184- k, err := New(locale)
185- if err != nil {
186- if os.Getenv("NEFERGUI_SKIP_XKB") == "1" {
187- t.Skipf("xkb unavailable: %v", err)
188- }
189- t.Fatal(err)
190- }
191- return k
192-}
193-
194-func TestRepeatTracksCurrentMaskAndCompose(t *testing.T) {
195- k := requiredKeyboard(t, "en_US.UTF-8")
196- defer k.Close()
197- if err := k.Rules("us,fr", ""); err != nil {
198- t.Fatal(err)
199- }
200- now := time.Now()
201- k.RepeatInfo(10, 100*time.Millisecond)
202- press, err := k.Event(16, true, now)
203- if err != nil || press.Text != "q" {
204- t.Fatalf("press: %+v %v", press, err)
205- }
206- shift, err := k.keymap.ModIndex("Shift")
207- if err != nil {
208- t.Fatal(err)
209- }
210- if err = k.Mask(1<<shift, 0, 0, 0, 0, 0); err != nil {
211- t.Fatal(err)
212- }
213- rep, ok := k.Tick(now.Add(100 * time.Millisecond))
214- if !ok || rep.Text != "Q" || rep.Logical != raw.XKB_KEY_Q || !rep.Repeat || !rep.Shift || rep.Ctrl {
215- t.Fatalf("shift repeat: %+v %t", rep, ok)
216- }
217- if err = k.Mask(0, 0, 0, 0, 0, 1); err != nil {
218- t.Fatal(err)
219- }
220- rep, ok = k.Tick(now.Add(200 * time.Millisecond))
221- if !ok || rep.Text != "a" || rep.Group != 1 {
222- t.Fatalf("layout repeat: %+v %t", rep, ok)
223- }
224- k.Focus(false)
225- k.Focus(true)
226- if err = k.Rules("de", ""); err != nil {
227- t.Fatal(err)
228- }
229- k.Event(41, true, now)
230- k.Event(41, false, now)
231- // A repeat must not replay the composed press-time character.
232- composed, err := k.Event(18, true, now)
233- if err != nil || composed.Text != "ê" {
234- t.Fatalf("compose press: %+v %v", composed, err)
235- }
236- rep, ok = k.Tick(now.Add(100 * time.Millisecond))
237- if !ok || rep.Text != "e" {
238- t.Fatalf("post-compose repeat: %+v %t", rep, ok)
239- }
240-}
D internal/keyboard/secret.go +0 -234
@@ -1,234 +0,0 @@
1-package keyboard
2-
3-import (
4- "errors"
5- "time"
6- "unicode/utf8"
7-
8- xkb "github.com/bnema/purego-xkbcommon"
9- "github.com/bnema/purego-xkbcommon/raw"
10-)
11-
12-// SecretKind is the limited editing vocabulary accepted by secret consumers.
13-type SecretKind uint8
14-
15-const (
16- SecretNone SecretKind = iota
17- SecretBytes
18- SecretBackspace
19- SecretReturn
20- SecretEscape
21- SecretClear // Control+U
22-)
23-
24-// SecretKey contains scalar metadata only, never names or text. Bytes are
25-// supplied separately and exclusively within EventSecret/TickSecret callbacks.
26-type SecretKey struct {
27- Physical, Logical uint32
28- Kind SecretKind
29- Pressed, Repeat bool
30-}
31-
32-var errSecretCallback = errors.New("keyboard: secret callback is nil")
33-
34-// ClearSecret cancels secret-held keys, repeat and local compose. It retains
35-// the compositor's current keymap and modifier mask. No text is cached here.
36-func (k *Keyboard) ClearSecret() {
37- clear(k.secretHeld)
38- clear(k.held) // fail-closed even when secret entry fails from ordinary mode
39- k.repeating = 0
40- k.next = time.Time{}
41- k.composePending = false
42- if k.compose != nil {
43- _ = k.compose.Reset()
44- }
45-}
46-
47-// EventSecret interprets a physical event without calling any string-producing
48-// XKB API or populating Key/held. scratch length must be 1..512, including space
49-// for native NUL (max payload 511). The callback is synchronous and must not
50-// retain bytes or convert them to strings. scratch is valid only for that call;
51-// this method clears it on entry and every exit, in addition to caller wiping.
52-// Enter, Escape and Control+U are initial-press controls, never repeats.
53-// Release callbacks carry no bytes and Pressed=false; consumers must act on
54-// controls only when Pressed=true. Switching APIs cancels the other path.
55-func (k *Keyboard) EventSecret(evdev uint32, pressed bool, now time.Time, scratch []byte, consume func(SecretKey, []byte)) error {
56- clear(scratch)
57- defer clear(scratch)
58- if len(scratch) < 1 || len(scratch) > xkb.MaxUTF8Buffer {
59- k.ClearSecret()
60- return xkb.ErrUTF8Buffer
61- }
62- if consume == nil {
63- k.ClearSecret()
64- return errSecretCallback
65- }
66- if !k.secretMode {
67- k.Focus(k.focus) // discard ordinary held text and compose on mode entry
68- k.secretMode = true
69- if k.secretHeld == nil {
70- k.secretHeld = make(map[uint32]SecretKey)
71- }
72- }
73- if !pressed {
74- key, ok := k.secretHeld[evdev]
75- delete(k.secretHeld, evdev)
76- if k.repeating == evdev {
77- k.repeating = 0
78- }
79- if ok && key.Kind != SecretNone {
80- key.Pressed = false
81- consume(key, nil)
82- }
83- return nil
84- }
85- if !k.focus || k.state == nil {
86- return nil
87- }
88- _, repeat := k.secretHeld[evdev]
89- key, n, err := k.secretKey(evdev, repeat, scratch)
90- if err != nil {
91- k.ClearSecret()
92- return err
93- }
94- k.secretHeld[evdev] = key
95- if key.Kind == SecretReturn || key.Kind == SecretEscape || key.Kind == SecretClear {
96- // Clear/submit/cancel must stop a previously held printable repeat
97- // before the consumer runs; releasing Control must not resurrect it.
98- k.repeating = 0
99- k.next = time.Time{}
100- }
101- if k.rate > 0 && secretRepeatable(key) {
102- k.repeating = evdev
103- k.next = now.Add(k.delay)
104- }
105- if key.Kind != SecretNone {
106- consume(key, scratch[:n:n])
107- }
108- return nil
109-}
110-
111-func secretRepeatable(key SecretKey) bool {
112- return key.Kind == SecretBackspace || key.Kind == SecretBytes && repeatable(key.Logical)
113-}
114-
115-func (k *Keyboard) secretKey(evdev uint32, repeat bool, scratch []byte) (SecretKey, int, error) {
116- code := xkb.WaylandKeycode(evdev)
117- sym, err := k.state.KeySym(code)
118- if err != nil {
119- return SecretKey{}, 0, err
120- }
121- key := SecretKey{Physical: evdev, Logical: sym, Pressed: true, Repeat: repeat}
122- _, ctrl, err := k.modifiers()
123- if err != nil {
124- return SecretKey{}, 0, err
125- }
126- switch {
127- case sym == raw.XKB_KEY_BackSpace:
128- key.Kind = SecretBackspace
129- case sym == raw.XKB_KEY_Return || sym == raw.XKB_KEY_KP_Enter:
130- key.Kind = SecretReturn
131- case sym == raw.XKB_KEY_Escape:
132- key.Kind = SecretEscape
133- case ctrl && (sym == raw.XKB_KEY_u || sym == raw.XKB_KEY_U):
134- key.Kind = SecretClear
135- }
136- if key.Kind != SecretNone {
137- if k.compose != nil {
138- _ = k.compose.Reset()
139- }
140- k.composePending = false
141- if repeat && key.Kind != SecretBackspace {
142- key.Kind = SecretNone
143- }
144- return key, 0, nil
145- }
146- // Other Control combinations are not secret bytes or application shortcuts.
147- if ctrl {
148- return key, 0, nil
149- }
150- if k.compose != nil && !repeat {
151- if _, err := k.compose.Feed(sym); err != nil {
152- return key, 0, err
153- }
154- status, err := k.compose.Status()
155- if err != nil {
156- return key, 0, err
157- }
158- switch status {
159- case raw.XKB_COMPOSE_COMPOSING:
160- k.composePending = true
161- return key, 0, nil
162- case raw.XKB_COMPOSE_CANCELLED:
163- _ = k.compose.Reset()
164- k.composePending = false
165- return key, 0, nil
166- case raw.XKB_COMPOSE_COMPOSED:
167- n, err := k.compose.UTF8Into(scratch)
168- _ = k.compose.Reset()
169- k.composePending = false
170- if err != nil {
171- return key, 0, err
172- }
173- return classifySecretBytes(key, scratch, n), n, nil
174- default:
175- k.composePending = false
176- }
177- }
178- if k.composePending {
179- return key, 0, nil
180- }
181- n, err := k.state.UTF8Into(code, scratch)
182- if err != nil {
183- return key, 0, err
184- }
185- return classifySecretBytes(key, scratch, n), n, nil
186-}
187-
188-func classifySecretBytes(key SecretKey, scratch []byte, n int) SecretKey {
189- if n == 0 || !utf8.Valid(scratch[:n]) {
190- return key
191- }
192- for rest := scratch[:n]; len(rest) > 0; {
193- r, size := utf8.DecodeRune(rest)
194- if r < 0x20 || r == 0x7f {
195- return key
196- }
197- rest = rest[size:]
198- }
199- key.Kind = SecretBytes
200- return key
201-}
202-
203-// TickSecret resolves repeat bytes using the current mask/group, not press-time
204-// plaintext. Compose is never fed by repeats. It reports whether a callback ran.
205-func (k *Keyboard) TickSecret(now time.Time, scratch []byte, consume func(SecretKey, []byte)) (bool, error) {
206- clear(scratch)
207- defer clear(scratch)
208- if len(scratch) < 1 || len(scratch) > xkb.MaxUTF8Buffer {
209- k.ClearSecret()
210- return false, xkb.ErrUTF8Buffer
211- }
212- if consume == nil {
213- k.ClearSecret()
214- return false, errSecretCallback
215- }
216- if !k.secretMode || !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) {
217- return false, nil
218- }
219- if _, ok := k.secretHeld[k.repeating]; !ok {
220- return false, nil
221- }
222- key, n, err := k.secretKey(k.repeating, true, scratch)
223- if err != nil {
224- k.ClearSecret()
225- return false, err
226- }
227- if !secretRepeatable(key) {
228- k.repeating = 0
229- return false, nil
230- }
231- k.next = now.Add(time.Second / time.Duration(k.rate))
232- consume(key, scratch[:n:n])
233- return true, nil
234-}
D internal/keyboard/secret_test.go +0 -414
@@ -1,414 +0,0 @@
1-package keyboard
2-
3-import (
4- "bytes"
5- "errors"
6- "io"
7- "reflect"
8- "testing"
9- "time"
10-
11- xkb "github.com/bnema/purego-xkbcommon"
12- "github.com/bnema/purego-xkbcommon/raw"
13-)
14-
15-func TestSecretASCIIAndMultibyteWithoutRetainedText(t *testing.T) {
16- k := requiredKeyboard(t, "en_US.UTF-8")
17- defer k.Close()
18- for _, tc := range []struct {
19- layout string
20- code uint32
21- mod string
22- want []byte
23- }{
24- {"us", 16, "", []byte{'q'}}, {"de", 18, "Mod5", []byte{0xe2, 0x82, 0xac}},
25- } {
26- if err := k.Rules(tc.layout, ""); err != nil {
27- t.Fatal(err)
28- }
29- if tc.mod != "" {
30- idx, err := k.keymap.ModIndex(tc.mod)
31- if err != nil {
32- t.Fatal(err)
33- }
34- if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil {
35- t.Fatal(err)
36- }
37- }
38- var scratch [512]byte
39- calls := 0
40- err := k.EventSecret(tc.code, true, time.Now(), scratch[:], func(key SecretKey, data []byte) {
41- calls++
42- if key.Kind != SecretBytes || !key.Pressed || key.Repeat || !bytes.Equal(data, tc.want) {
43- t.Fatal("wrong scoped bytes/metadata")
44- }
45- if cap(data) != len(data) {
46- t.Fatal("callback can access scratch beyond payload")
47- }
48- if len(data) > 0 && &data[0] != &scratch[0] {
49- t.Fatal("bytes copied instead of caller scratch")
50- }
51- })
52- if err != nil || calls != 1 {
53- t.Fatal(calls, err)
54- }
55- if !bytes.Equal(scratch[:], make([]byte, len(scratch))) || len(k.held) != 0 {
56- t.Fatal("plaintext retained")
57- }
58- if key := k.secretHeld[tc.code]; key.Kind != SecretBytes || key.Physical != tc.code {
59- t.Fatal("missing scalar held metadata")
60- }
61- }
62- for _, typ := range []reflect.Type{reflect.TypeFor[SecretKey]()} {
63- for i := 0; i < typ.NumField(); i++ {
64- if typ.Field(i).Type.Kind() == reflect.String || typ.Field(i).Type.Kind() == reflect.Slice {
65- t.Fatal("secret metadata retains payload")
66- }
67- }
68- }
69-}
70-
71-func TestSecretComposeAndClear(t *testing.T) {
72- k := requiredKeyboard(t, "en_US.UTF-8")
73- defer k.Close()
74- if err := k.Rules("de", ""); err != nil {
75- t.Fatal(err)
76- }
77- var scratch [512]byte
78- now := time.Now()
79- calls := 0
80- consume := func(key SecretKey, data []byte) {
81- if !key.Pressed {
82- return
83- }
84- calls++
85- if key.Kind != SecretBytes || !bytes.Equal(data, []byte{0xc3, 0xaa}) {
86- t.Fatal("compose bytes incorrect")
87- }
88- }
89- for _, ev := range []struct {
90- code uint32
91- down bool
92- }{{41, true}, {41, false}, {18, true}} {
93- if err := k.EventSecret(ev.code, ev.down, now, scratch[:], consume); err != nil {
94- t.Fatal(err)
95- }
96- }
97- if calls != 1 || k.composePending {
98- t.Fatal("compose did not finish")
99- }
100- k.ClearSecret()
101- if len(k.secretHeld) != 0 || k.repeating != 0 {
102- t.Fatal("clear retained held/repeat")
103- }
104- if err := k.EventSecret(41, true, now, scratch[:], consume); err != nil {
105- t.Fatal(err)
106- }
107- k.Focus(false)
108- k.Focus(true)
109- if err := k.EventSecret(18, true, now, scratch[:], func(key SecretKey, data []byte) {
110- if !bytes.Equal(data, []byte{'e'}) {
111- t.Fatal("focus loss preserved compose")
112- }
113- }); err != nil {
114- t.Fatal(err)
115- }
116-}
117-
118-func TestSecretControlsAndRepeat(t *testing.T) {
119- k := requiredKeyboard(t, "en_US.UTF-8")
120- defer k.Close()
121- if err := k.Rules("us", ""); err != nil {
122- t.Fatal(err)
123- }
124- k.RepeatInfo(10, 100*time.Millisecond)
125- now := time.Now()
126- var scratch [512]byte
127- for _, tc := range []struct {
128- code uint32
129- kind SecretKind
130- repeat bool
131- }{{14, SecretBackspace, true}, {28, SecretReturn, false}, {1, SecretEscape, false}} {
132- k.ClearSecret()
133- calls := 0
134- consume := func(key SecretKey, data []byte) {
135- if len(data) != 0 || key.Kind != tc.kind {
136- t.Fatal("control leaked text")
137- }
138- calls++
139- }
140- if err := k.EventSecret(tc.code, true, now, scratch[:], consume); err != nil {
141- t.Fatal(err)
142- }
143- if err := k.EventSecret(tc.code, true, now, scratch[:], consume); err != nil {
144- t.Fatal(err)
145- }
146- got, err := k.TickSecret(now.Add(100*time.Millisecond), scratch[:], consume)
147- if err != nil || got != tc.repeat {
148- t.Fatal(got, err)
149- }
150- want := 1
151- if tc.repeat {
152- want = 3
153- }
154- if calls != want {
155- t.Fatalf("control callbacks=%d want=%d", calls, want)
156- }
157- }
158- k.ClearSecret()
159- idx, err := k.keymap.ModIndex("Control")
160- if err != nil {
161- t.Fatal(err)
162- }
163- if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil {
164- t.Fatal(err)
165- }
166- if err := k.EventSecret(22, true, now, scratch[:], func(key SecretKey, data []byte) {
167- if key.Kind != SecretClear || len(data) != 0 {
168- t.Fatal("CtrlU not named control")
169- }
170- }); err != nil {
171- t.Fatal(err)
172- }
173- k.ClearSecret()
174- _ = k.Mask(0, 0, 0, 0, 0, 0)
175- if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil {
176- t.Fatal(err)
177- }
178- shift, _ := k.keymap.ModIndex("Shift")
179- _ = k.Mask(1<<shift, 0, 0, 0, 0, 0)
180- got, err := k.TickSecret(now.Add(100*time.Millisecond), scratch[:], func(key SecretKey, data []byte) {
181- if !key.Repeat || key.Logical != raw.XKB_KEY_Q || !bytes.Equal(data, []byte{'Q'}) {
182- t.Fatal("repeat cached press-time bytes")
183- }
184- })
185- if err != nil || !got {
186- t.Fatal(got, err)
187- }
188- if _, ok := k.Tick(now.Add(time.Second)); ok {
189- t.Fatal("ordinary repeat exposed secret")
190- }
191- k.Focus(false)
192- if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("repeat after focus loss") }); got || err != nil {
193- t.Fatal(got, err)
194- }
195-}
196-
197-func TestSecretBoundsAndOrdinaryIsolation(t *testing.T) {
198- k := requiredKeyboard(t, "")
199- defer k.Close()
200- if err := k.Rules("us", ""); err != nil {
201- t.Fatal(err)
202- }
203- now := time.Now()
204- if _, err := k.Event(16, true, now); err != nil {
205- t.Fatal(err)
206- }
207- for _, size := range []int{0, 513} {
208- buf := make([]byte, size)
209- if err := k.EventSecret(30, true, now, buf, func(SecretKey, []byte) { t.Fatal("invalid bound callback") }); !errors.Is(err, xkb.ErrUTF8Buffer) {
210- t.Fatal(err)
211- }
212- }
213- short := []byte{0xff}
214- if err := k.EventSecret(30, true, now, short, func(SecretKey, []byte) { t.Fatal("truncated callback") }); !errors.Is(err, io.ErrShortBuffer) {
215- t.Fatal(err)
216- }
217- if short[0] != 0 || len(k.held) != 0 || len(k.secretHeld) != 0 {
218- t.Fatal("error retained bytes/state")
219- }
220- var scratch [512]byte
221- if err := k.EventSecret(30, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil {
222- t.Fatal(err)
223- }
224- if key, err := k.Event(30, false, now); err != nil || key.Text != "" || key.Name != "" {
225- t.Fatal("ordinary release exposed secret")
226- }
227-}
228-
229-func TestSecretRepeatBoundedAllocations(t *testing.T) {
230- k := requiredKeyboard(t, "")
231- defer k.Close()
232- if err := k.Rules("us", ""); err != nil {
233- t.Fatal(err)
234- }
235- k.RepeatInfo(20, 0)
236- var scratch [512]byte
237- now := time.Now()
238- consume := func(SecretKey, []byte) {}
239- if err := k.EventSecret(16, true, now, scratch[:], consume); err != nil {
240- t.Fatal(err)
241- }
242- allocs := testing.AllocsPerRun(100, func() {
243- now = now.Add(time.Second)
244- if _, err := k.TickSecret(now, scratch[:], consume); err != nil {
245- t.Fatal(err)
246- }
247- })
248- // Fixed native-dispatch/modifier-query overhead is allowed; no allocation
249- // should scale with payload length or retain a text-bearing Key.
250- t.Logf("secret repeat fixed allocations: %v", allocs)
251- if allocs > 24 {
252- t.Fatalf("secret repeat allocations=%v", allocs)
253- }
254-}
255-
256-func TestSecretControlsCancelPriorPrintableRepeat(t *testing.T) {
257- for _, tc := range []struct {
258- code uint32
259- kind SecretKind
260- ctrl bool
261- }{{28, SecretReturn, false}, {1, SecretEscape, false}, {22, SecretClear, true}} {
262- k := requiredKeyboard(t, "en_US.UTF-8")
263- if err := k.Rules("us", ""); err != nil {
264- t.Fatal(err)
265- }
266- k.RepeatInfo(20, 0)
267- now := time.Now()
268- var scratch [512]byte
269- if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil {
270- t.Fatal(err)
271- }
272- if tc.ctrl {
273- idx, err := k.keymap.ModIndex("Control")
274- if err != nil {
275- t.Fatal(err)
276- }
277- if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil {
278- t.Fatal(err)
279- }
280- }
281- if err := k.EventSecret(tc.code, true, now, scratch[:], func(key SecretKey, data []byte) {
282- if key.Kind != tc.kind || len(data) != 0 || k.repeating != 0 || !k.next.IsZero() {
283- t.Fatal("control callback preceded repeat cancellation")
284- }
285- }); err != nil {
286- t.Fatal(err)
287- }
288- if err := k.Mask(0, 0, 0, 0, 0, 0); err != nil {
289- t.Fatal(err)
290- }
291- if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("bytes after control") }); got || err != nil {
292- t.Fatal(got, err)
293- }
294- k.Close()
295- }
296-}
297-
298-func TestSecretValidationErrorsClearAllInputState(t *testing.T) {
299- for _, ordinary := range []bool{false, true} {
300- for _, tick := range []bool{false, true} {
301- for _, invalid := range []string{"empty", "oversize", "nil-callback"} {
302- k := requiredKeyboard(t, "en_US.UTF-8")
303- if err := k.Rules("de", ""); err != nil {
304- t.Fatal(err)
305- }
306- k.RepeatInfo(20, 0)
307- now := time.Now()
308- var scratch [512]byte
309- if ordinary {
310- if _, err := k.Event(16, true, now); err != nil {
311- t.Fatal(err)
312- }
313- } else {
314- if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil {
315- t.Fatal(err)
316- }
317- }
318- // Real native composing state must also be reset on validation failure.
319- if _, err := k.compose.Feed(raw.XKB_KEY_dead_circumflex); err != nil {
320- t.Fatal(err)
321- }
322- k.composePending = true
323- buf := scratch[:]
324- consume := func(SecretKey, []byte) { t.Fatal("invalid call delivered") }
325- want := xkb.ErrUTF8Buffer
326- switch invalid {
327- case "empty":
328- buf = nil
329- case "oversize":
330- buf = make([]byte, 513)
331- case "nil-callback":
332- consume = nil
333- want = errSecretCallback
334- }
335- var err error
336- if tick {
337- _, err = k.TickSecret(now.Add(time.Second), buf, consume)
338- } else {
339- err = k.EventSecret(16, false, now, buf, consume)
340- } // release validation must not retain held state
341- if !errors.Is(err, want) {
342- t.Fatal(invalid, err)
343- }
344- status, err := k.compose.Status()
345- if err != nil || status != raw.XKB_COMPOSE_NOTHING {
346- t.Fatal("compose survived validation error")
347- }
348- if len(k.held) != 0 || len(k.secretHeld) != 0 || k.repeating != 0 || !k.next.IsZero() || k.composePending {
349- t.Fatal("validation error retained ordinary/secret state")
350- }
351- if _, ok := k.Tick(now.Add(time.Second)); ok {
352- t.Fatal("ordinary repeat after failed secret entry")
353- }
354- if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("secret repeat after failed validation") }); got || err != nil {
355- t.Fatal(got, err)
356- }
357- k.Close()
358- }
359- }
360- }
361-}
362-
363-func TestSecretCallbackPanicWipesScratch(t *testing.T) {
364- k := requiredKeyboard(t, "")
365- defer k.Close()
366- if err := k.Rules("us", ""); err != nil {
367- t.Fatal(err)
368- }
369- var scratch [512]byte
370- func() {
371- defer func() {
372- if recover() == nil {
373- t.Fatal("callback did not panic")
374- }
375- }()
376- _ = k.EventSecret(16, true, time.Now(), scratch[:], func(_ SecretKey, data []byte) {
377- if !bytes.Equal(data, []byte{'q'}) {
378- t.Fatal("no bytes before panic")
379- }
380- panic("consumer failure")
381- })
382- }()
383- if !bytes.Equal(scratch[:], make([]byte, len(scratch))) {
384- t.Fatal("panic retained scratch payload")
385- }
386-}
387-
388-func TestSecretRepeatTracksLayoutGroup(t *testing.T) {
389- k := requiredKeyboard(t, "")
390- defer k.Close()
391- if err := k.Rules("us,fr", ""); err != nil {
392- t.Fatal(err)
393- }
394- k.RepeatInfo(20, 0)
395- now := time.Now()
396- var scratch [512]byte
397- if err := k.EventSecret(16, true, now, scratch[:], func(_ SecretKey, data []byte) {
398- if !bytes.Equal(data, []byte{'q'}) {
399- t.Fatal("initial group")
400- }
401- }); err != nil {
402- t.Fatal(err)
403- }
404- if err := k.Mask(0, 0, 0, 0, 0, 1); err != nil {
405- t.Fatal(err)
406- }
407- if ok, err := k.TickSecret(now.Add(time.Second), scratch[:], func(key SecretKey, data []byte) {
408- if !key.Repeat || !bytes.Equal(data, []byte{'a'}) {
409- t.Fatal("repeat cached previous group")
410- }
411- }); err != nil || !ok {
412- t.Fatal(ok, err)
413- }
414-}
D internal/keyboard/testdata/fr.xkb +0 -2084 large diff (2084 lines changed): collapsed
D internal/platform/wayland/clipboard.go +0 -391
@@ -1,391 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "context"
7- "errors"
8- "fmt"
9- "os"
10- "sync"
11- "syscall"
12- "time"
13-
14- "github.com/bnema/nefergui/internal/edit"
15- "github.com/bnema/wlturbo"
16- "github.com/bnema/wlturbo/protocol/core"
17- "github.com/bnema/wlturbo/wl"
18- "golang.org/x/sys/unix"
19-)
20-
21-const (
22- mimeUTF8 = "text/plain;charset=utf-8"
23- mimeX11 = "UTF8_STRING"
24- mimePlain = "text/plain"
25- transferTimeout = 3 * time.Second
26-)
27-
28-// ClipboardResult is delivered through the session event queue, never from
29-// the reader into the editor. Data may contain invalid UTF-8; edit validates it.
30-type ClipboardResult struct {
31- Data []byte
32- Err error
33-}
34-type ClipboardEvent struct {
35- kind string
36- offer *core.DataOffer
37- mime string
38- source *core.DataSource
39-}
40-
41-// Clipboard is the asynchronous Wayland clipboard adapter. The synchronous
42-// edit.Clipboard.ReadText contract cannot be used on the UI loop: callers must
43-// use ReadTextAsync and dispatch ClipboardResult on their next frame.
44-type Clipboard struct {
45- manager *core.DataDeviceManager
46- version uint32 // negotiated wl_data_device_manager version
47- device *core.DataDevice
48- offer *core.DataOffer
49- dnd *core.DataOffer
50- pending map[*core.DataOffer]map[string]bool
51- offers map[uint32]*core.DataOffer
52- mimes map[string]bool
53- source *core.DataSource
54- serial func() uint32
55- post func(ClipboardEvent)
56- closed bool
57- stop context.CancelFunc
58- workers sync.WaitGroup
59- mu sync.Mutex
60-}
61-
62-var _ edit.AsyncClipboard = (*Clipboard)(nil)
63-
64-// NewClipboard binds the optional manager at its announced version and creates
65-// one data device for seat. Call from the session loop after seat binding.
66-func NewClipboard(d *wl.Display, seat *core.Seat, serial func() uint32, post func(ClipboardEvent)) (*Clipboard, error) {
67- if seat == nil || d == nil {
68- return nil, nil
69- }
70- manager := core.NewDataDeviceManager(d.Context())
71- version, err := d.Registry().BindNegotiated(core.DataDeviceManagerInterface, 3, manager)
72- if err != nil {
73- d.Context().Unregister(manager)
74- if errors.Is(err, wlturbo.ErrGlobalNotFound) {
75- return nil, nil
76- }
77- return nil, err
78- }
79- device, err := manager.GetDataDevice(seat)
80- if err != nil {
81- d.Context().Unregister(manager)
82- return nil, err
83- }
84- c := &Clipboard{manager: manager, version: version, device: device, serial: serial, post: post, pending: make(map[*core.DataOffer]map[string]bool), offers: make(map[uint32]*core.DataOffer)}
85- device.OnDataOffer(func(o *core.DataOffer) {
86- c.mu.Lock()
87- if c.closed {
88- c.mu.Unlock()
89- _ = o.Destroy()
90- return
91- }
92- c.offers[o.ID()] = o
93- c.mu.Unlock()
94- c.post(ClipboardEvent{kind: "offer", offer: o})
95- o.OnOffer(func(m string) { c.post(ClipboardEvent{kind: "mime", offer: o, mime: m}) })
96- })
97- device.OnSelection(func(id uint32) { c.post(ClipboardEvent{kind: "selection", offer: c.findOffer(id)}) })
98- // Drag-and-drop is unsupported in V1; retain its offer until leave/drop.
99- device.OnEnter(func(_ uint32, _ uint32, _, _ wl.Fixed, id uint32) {
100- c.post(ClipboardEvent{kind: "enter", offer: c.findOffer(id)})
101- })
102- device.OnLeave(func() { c.post(ClipboardEvent{kind: "leave"}) })
103- device.OnDrop(func() { c.post(ClipboardEvent{kind: "drop"}) })
104- return c, nil
105-}
106-
107-// findOffer only resolves IDs from reader-owned notifications. It does not
108-// mutate clipboard state; the loop decides which offer is current.
109-func (c *Clipboard) findOffer(id uint32) *core.DataOffer {
110- if id == 0 {
111- return nil
112- }
113- c.mu.Lock()
114- defer c.mu.Unlock()
115- return c.offers[id]
116-}
117-
118-// ApplyClipboard handles reader events on the session loop.
119-func (c *Clipboard) ApplyClipboard(ev ClipboardEvent) {
120- switch ev.kind {
121- case "offer":
122- c.pending[ev.offer] = make(map[string]bool)
123- case "mime":
124- if m, ok := c.pending[ev.offer]; ok {
125- m[ev.mime] = true
126- } else if ev.offer == c.offer {
127- c.mimes[ev.mime] = true
128- }
129- case "selection":
130- if c.offer != nil && c.offer != ev.offer {
131- c.destroyOffer(c.offer)
132- }
133- c.offer = ev.offer
134- c.mimes = c.pending[ev.offer]
135- if c.mimes == nil {
136- c.mimes = make(map[string]bool)
137- }
138- delete(c.pending, ev.offer)
139- for o := range c.pending {
140- if o == c.dnd {
141- continue
142- }
143- if o != ev.offer {
144- c.destroyOffer(o)
145- delete(c.pending, o)
146- }
147- }
148- case "enter":
149- c.clearDnD()
150- c.dnd = ev.offer
151- delete(c.pending, ev.offer)
152- case "leave", "drop":
153- c.clearDnD()
154- case "cancelled":
155- if c.source == ev.source {
156- _ = c.source.Destroy()
157- c.source = nil
158- }
159- }
160-}
161-func (c *Clipboard) clearDnD() {
162- if c.dnd != nil && c.dnd != c.offer {
163- c.destroyOffer(c.dnd)
164- }
165- c.dnd = nil
166-}
167-func (c *Clipboard) destroyOffer(o *core.DataOffer) {
168- c.mu.Lock()
169- delete(c.offers, o.ID())
170- c.mu.Unlock()
171- _ = o.Destroy()
172-}
173-func (c *Clipboard) bestMIME() string {
174- for _, m := range []string{mimeUTF8, mimeX11, mimePlain} {
175- if c.mimes[m] {
176- return m
177- }
178- }
179- return ""
180-}
181-
182-// ReadTextAsync must be called on the loop. The compositor receives the write
183-// end; a worker reads at most limit+1 bytes. The caller posts the callback to
184-// its UI event queue. Concurrent reads cancel the previous transfer. Close
185-// cancels and joins reads, not callbacks: a callback racing Close may still run
186-// after Close returns. Callers must discard results when shutting down.
187-func (c *Clipboard) ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error {
188- if c == nil || c.offer == nil {
189- return errors.New("clipboard unavailable")
190- }
191- mime := c.bestMIME()
192- if mime == "" {
193- return errors.New("no text MIME offered")
194- }
195- if limit < 0 || limit > edit.MaxClipboardBytes {
196- limit = edit.MaxClipboardBytes
197- }
198- fd := make([]int, 2)
199- if err := unix.Pipe2(fd, unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
200- return err
201- }
202- r := os.NewFile(uintptr(fd[0]), "clipboard-read")
203- if err := c.offer.Receive(mime, fd[1]); err != nil {
204- _ = syscall.Close(fd[1])
205- _ = r.Close()
206- return err
207- }
208- // WLTurbo closes the sent FD after a successful send; do not close it twice.
209- ctx, cancel := context.WithTimeout(ctx, transferTimeout)
210- c.mu.Lock()
211- if c.closed {
212- c.mu.Unlock()
213- cancel()
214- _ = r.Close()
215- return errors.New("clipboard closed")
216- }
217- if c.stop != nil {
218- c.stop()
219- }
220- c.stop = cancel
221- c.workers.Add(1)
222- c.mu.Unlock()
223- go func() {
224- defer c.workers.Done()
225- defer cancel()
226- defer r.Close()
227- // readClipboard polls ctx every 50ms; closing r from another goroutine
228- // while it is read would race on the descriptor.
229- data, e := readClipboard(ctx, r, limit)
230- if ctx.Err() != nil {
231- e = ctx.Err()
232- }
233- c.mu.Lock()
234- open := !c.closed
235- c.mu.Unlock()
236- if !open || ctx.Err() != nil {
237- return
238- }
239- // Arbitrary callbacks cannot be forcibly interrupted. They are not
240- // joined by Close: a blocked UI enqueue must not stall shutdown.
241- go deliver(data, e)
242- }()
243- return nil
244-}
245-
246-// readClipboard polls a nonblocking pipe so cancellation and timeouts are
247-// effective even if the compositor never closes its write end.
248-func readClipboard(ctx context.Context, r *os.File, limit int) ([]byte, error) {
249- data := make([]byte, 0, min(limit, 4096))
250- buf := make([]byte, 4096)
251- for {
252- if err := ctx.Err(); err != nil {
253- return nil, err
254- }
255- n, e := unix.Read(int(r.Fd()), buf[:min(len(buf), limit+1-len(data))])
256- if n > 0 {
257- data = append(data, buf[:n]...)
258- if len(data) > limit {
259- return nil, fmt.Errorf("clipboard exceeds %d bytes", limit)
260- }
261- }
262- if e == nil && n == 0 {
263- return data, nil
264- }
265- if e == nil {
266- continue
267- }
268- if e != unix.EAGAIN && e != unix.EINTR {
269- return nil, e
270- }
271- poll := []unix.PollFd{{Fd: int32(r.Fd()), Events: unix.POLLIN | unix.POLLHUP}}
272- _, e = unix.Poll(poll, 50)
273- if e != nil && e != unix.EINTR {
274- return nil, e
275- }
276- }
277-}
278-
279-// ReadText deliberately refuses to block the UI loop. Use ReadTextAsync.
280-func (c *Clipboard) ReadText(int) ([]byte, error) {
281- return nil, errors.New("wayland clipboard requires ReadTextAsync")
282-}
283-
284-func (c *Clipboard) WriteText(data []byte) error {
285- if c == nil || c.device == nil {
286- return errors.New("clipboard unavailable")
287- }
288- c.mu.Lock()
289- closed := c.closed
290- c.mu.Unlock()
291- if closed {
292- return errors.New("clipboard closed")
293- }
294- if len(data) > edit.MaxClipboardBytes {
295- return errors.New("clipboard too large")
296- }
297- serial := c.serial()
298- if serial == 0 {
299- return errors.New("no keyboard selection serial")
300- }
301- source, err := c.manager.CreateDataSource()
302- if err != nil {
303- return err
304- }
305- for _, m := range []string{mimeUTF8, mimeX11, mimePlain} {
306- if err = source.Offer(m); err != nil {
307- _ = source.Destroy()
308- return err
309- }
310- }
311- payload := append([]byte(nil), data...)
312- source.OnSend(func(m string, fd *wl.OwnedFD) {
313- n, e := fd.Take()
314- if e != nil {
315- return
316- }
317- if m != mimeUTF8 && m != mimeX11 && m != mimePlain {
318- _ = os.NewFile(uintptr(n), "selection").Close()
319- return
320- }
321- go writeSelection(n, payload)
322- })
323- source.OnCancelled(func() { c.post(ClipboardEvent{kind: "cancelled", source: source}) })
324- if err = c.device.SetSelection(source, serial); err != nil {
325- _ = source.Destroy()
326- return err
327- }
328- if c.source != nil {
329- _ = c.source.Destroy()
330- }
331- c.source = source
332- return nil
333-}
334-func writeSelection(fd int, data []byte) {
335- f := os.NewFile(uintptr(fd), "selection")
336- defer f.Close()
337- _ = unix.SetNonblock(fd, true)
338- deadline := time.Now().Add(transferTimeout)
339- for len(data) > 0 && time.Now().Before(deadline) {
340- n, err := unix.Write(fd, data)
341- if n > 0 {
342- data = data[n:]
343- }
344- if err != nil && err != unix.EAGAIN && err != unix.EINTR {
345- return
346- }
347- if n == 0 || err == unix.EAGAIN {
348- _, _ = unix.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}}, 50)
349- }
350- }
351-}
352-func (c *Clipboard) Close() {
353- if c == nil {
354- return
355- }
356- c.mu.Lock()
357- c.closed = true
358- if c.stop != nil {
359- c.stop()
360- }
361- c.mu.Unlock()
362- c.workers.Wait() // reads poll cancellation every 50ms (transfer has a 3s deadline)
363- c.clearDnD()
364- if c.offer != nil {
365- c.destroyOffer(c.offer)
366- }
367- for o := range c.pending {
368- if o != c.offer {
369- c.destroyOffer(o)
370- }
371- }
372- if c.source != nil {
373- _ = c.source.Destroy()
374- }
375- if c.device != nil {
376- if c.version >= 2 {
377- _ = c.device.Release()
378- } else {
379- if ctx := c.device.Context(); ctx != nil {
380- ctx.Unregister(c.device)
381- }
382- }
383- }
384- // wl_data_device_manager has no destructor below version 4: sending
385- // release is a protocol error, so only forget the proxy locally.
386- if c.manager != nil {
387- if ctx := c.manager.Context(); ctx != nil {
388- ctx.Unregister(c.manager)
389- }
390- }
391-}
D internal/platform/wayland/clipboard.md +0 -26
@@ -1,26 +0,0 @@
1-# Clipboard adapter
2-
3-`Clipboard` is constructed on the session loop with a seat, a function returning
4-the most recent keyboard enter/key serial, and a **reader-to-loop** event post
5-function. Deliver each `ClipboardEvent` to `ApplyClipboard` on that loop.
6-A missing `wl_data_device_manager` returns nil (clipboard unavailable).
7-Selection offers have independent MIME sets. Only the latest selected offer is
8-used; superseded offers and old sources are destroyed. Selection write offers
9-`text/plain;charset=utf-8`, `UTF8_STRING`, and `text/plain`. The same priority
10-is used when choosing a MIME for reads. FD-bearing send events transfer their
11-`OwnedFD` to a bounded-time writer; read transfers are bounded at the caller's
12-limit plus one byte (at most `edit.MaxClipboardBytes+1`) and timed out after
13-three seconds. Close the adapter before tearing down the data-device manager.
14-
15-The synchronous `edit.Clipboard.ReadText` contract would deadlock the UI loop:
16-Wayland receive requires another process to write a pipe while the UI must
17-continue dispatching. Therefore `edit.AsyncClipboard` is an additive port.
18-`ReadTextAsync` starts the transfer without waiting for the peer; its completion
19-callback runs on a **worker** and must enqueue a value for the next UI frame.
20-Never mutate the editor in that callback. Call `edit.State.PasteAsync` on the
21-UI loop to validate UTF-8 and bounds and apply the result. Synchronous
22-`ReadText` explicitly returns an error rather than blocking.
23-
24-Drag-and-drop is unsupported in V1. The data-device enter offer is tracked
25-separately from selection and discarded on leave, drop, or a subsequent enter;
26-selection replacement must not destroy an active drag offer.
D internal/platform/wayland/clipboard_extra_test.go +0 -293
@@ -1,293 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "context"
7- "errors"
8- "io"
9- "net"
10- "os"
11- "testing"
12- "time"
13-
14- "github.com/bnema/wlturbo/protocol/core"
15- "golang.org/x/sys/unix"
16-)
17-
18-func TestClipboardOfferReplacement(t *testing.T) {
19- c, d, server := clipboardFixture(t)
20- first := core.NewDataOffer(d.Context())
21- first.SetID(30)
22- d.Context().Register(first)
23- second := core.NewDataOffer(d.Context())
24- second.SetID(31)
25- d.Context().Register(second)
26- for _, o := range []*core.DataOffer{first, second} {
27- c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: o})
28- }
29- c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: first, mime: mimePlain})
30- c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: second, mime: mimeUTF8})
31- c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: second})
32- if c.offer != second || c.bestMIME() != mimeUTF8 {
33- t.Fatal("latest selection not used")
34- }
35- inputRequest(t, server) // old offer destroyed
36-}
37-func TestClipboardWriteSendOverSocketpair(t *testing.T) {
38- c, d, server := clipboardFixture(t)
39- // WriteText sends create_data_source, three offers and set_selection.
40- if err := c.WriteText([]byte("hello")); err != nil {
41- t.Fatal(err)
42- }
43- // Client writes can coalesce into a single socket read.
44- _ = server.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
45- _, _ = server.Read(make([]byte, 4096))
46- source := c.source
47- r, w, err := os.Pipe()
48- if err != nil {
49- t.Fatal(err)
50- }
51- defer r.Close()
52- defer w.Close()
53- // Wayland event string uses byte length with trailing NUL, padded to 4.
54- mime := append([]byte(mimeUTF8), 0)
55- padded := make([]byte, (len(mime)+3)&^3)
56- copy(padded, mime)
57- body := make([]byte, 4+len(padded))
58- body[0] = byte(len(mime))
59- copy(body[4:], padded)
60- msg := inputFrame(source.ID(), 1)
61- msg = append(msg, body...)
62- msg[6] = byte(len(msg))
63- msg[7] = byte(len(msg) >> 8)
64- inputSend(t, server, msg, int(w.Fd()))
65- if err = d.Dispatch(); err != nil {
66- t.Fatal(err)
67- }
68- data := make([]byte, 5)
69- _ = r.SetReadDeadline(time.Now().Add(time.Second))
70- if _, err = r.Read(data); err != nil || string(data) != "hello" {
71- t.Fatalf("send %q: %v", data, err)
72- }
73-}
74-func TestClipboardReadLimitAndTimeout(t *testing.T) {
75- fd := make([]int, 2)
76- if err := unix.Pipe2(fd, unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
77- t.Fatal(err)
78- }
79- r := os.NewFile(uintptr(fd[0]), "read")
80- defer r.Close()
81- defer unix.Close(fd[1])
82- if _, err := unix.Write(fd[1], []byte("123456")); err != nil {
83- t.Fatal(err)
84- }
85- if _, err := readClipboard(context.Background(), r, 5); err == nil {
86- t.Fatal("oversize accepted")
87- }
88- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
89- defer cancel()
90- if _, err := readClipboard(ctx, r, 5); !errors.Is(err, context.DeadlineExceeded) {
91- t.Fatalf("timeout: %v", err)
92- }
93-}
94-
95-func TestClipboardSendOwnershipAndCancellation(t *testing.T) {
96- c, d, server := clipboardFixture(t)
97- if err := c.WriteText([]byte("hello")); err != nil {
98- t.Fatal(err)
99- }
100- inputRequest(t, server)
101- source := c.source
102- for _, mime := range []string{mimeUTF8, "application/octet-stream"} {
103- r, w, err := os.Pipe()
104- if err != nil {
105- t.Fatal(err)
106- }
107- data := append([]byte(mime), 0)
108- padded := make([]byte, (len(data)+3)&^3)
109- copy(padded, data)
110- msg := inputFrame(source.ID(), 1, uint32(len(data)))
111- msg = append(msg, padded...)
112- msg[6], msg[7] = byte(len(msg)), byte(len(msg)>>8)
113- inputSend(t, server, msg, int(w.Fd()))
114- if err = d.Dispatch(); err != nil {
115- t.Fatal(err)
116- }
117- _ = w.Close()
118- _ = r.SetReadDeadline(time.Now().Add(time.Second))
119- buf := make([]byte, 8)
120- n, err := r.Read(buf)
121- if err != nil && err != io.EOF {
122- t.Fatal(err)
123- }
124- if mime == mimeUTF8 && string(buf[:n]) != "hello" || mime != mimeUTF8 && n != 0 {
125- t.Fatalf("send %s: %q %v", mime, buf[:n], err)
126- }
127- _ = r.Close()
128- }
129- inputSend(t, server, inputFrame(source.ID(), 2))
130- if err := d.Dispatch(); err != nil {
131- t.Fatal(err)
132- }
133- if c.source != nil {
134- t.Fatal("cancelled source retained")
135- }
136- inputRequest(t, server) // source.destroy
137-}
138-
139-func TestClipboardSelectionKeepsDnD(t *testing.T) {
140- c, d, server := clipboardFixture(t)
141- drag := core.NewDataOffer(d.Context())
142- drag.SetID(70)
143- d.Context().Register(drag)
144- selected := core.NewDataOffer(d.Context())
145- selected.SetID(71)
146- d.Context().Register(selected)
147- c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: drag})
148- c.ApplyClipboard(ClipboardEvent{kind: "enter", offer: drag})
149- c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: selected})
150- c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: selected})
151- if c.dnd != drag || c.offer != selected {
152- t.Fatal("lost active drag or selection")
153- }
154- _ = server.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
155- if _, err := server.Read(make([]byte, 128)); err == nil {
156- t.Fatal("active drag destroyed")
157- }
158- c.ApplyClipboard(ClipboardEvent{kind: "drop"})
159- if c.dnd != nil {
160- t.Fatal("drop retained drag")
161- }
162- inputRequest(t, server)
163-}
164-
165-func TestClipboardCloseCancelsReadWithoutDelivery(t *testing.T) {
166- c, d, server := clipboardFixture(t)
167- offer := core.NewDataOffer(d.Context())
168- offer.SetID(80)
169- d.Context().Register(offer)
170- c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer})
171- c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: mimePlain})
172- c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer})
173- delivered := make(chan struct{}, 1)
174- if err := c.ReadTextAsync(context.Background(), 32, func([]byte, error) { delivered <- struct{}{} }); err != nil {
175- t.Fatal(err)
176- }
177- // Keep the compositor's write end open so the read cannot finish before Close.
178- buf, oob := make([]byte, 256), make([]byte, 128)
179- _ = server.SetReadDeadline(time.Now().Add(time.Second))
180- _, n, _, _, err := server.ReadMsgUnix(buf, oob)
181- if err != nil {
182- t.Fatal(err)
183- }
184- msgs, err := unix.ParseSocketControlMessage(oob[:n])
185- if err != nil {
186- t.Fatal(err)
187- }
188- for _, msg := range msgs {
189- fds, err := unix.ParseUnixRights(&msg)
190- if err != nil {
191- t.Fatal(err)
192- }
193- for _, fd := range fds {
194- defer unix.Close(fd)
195- }
196- }
197- done := make(chan struct{})
198- go func() { c.Close(); close(done) }()
199- select {
200- case <-done:
201- case <-time.After(time.Second):
202- t.Fatal("Close did not wait for cancelled read")
203- }
204- select {
205- case <-delivered:
206- t.Fatal("delivered after Close")
207- default:
208- }
209-}
210-
211-func TestClipboardCloseInsideDelivery(t *testing.T) {
212- c, d, server := clipboardFixture(t)
213- offer := core.NewDataOffer(d.Context())
214- offer.SetID(81)
215- d.Context().Register(offer)
216- c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer})
217- c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: mimePlain})
218- c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer})
219- done := make(chan struct{})
220- if err := c.ReadTextAsync(context.Background(), 32, func([]byte, error) { c.Close(); close(done) }); err != nil {
221- t.Fatal(err)
222- }
223- clipboardFinishRead(t, server)
224- select {
225- case <-done:
226- case <-time.After(time.Second):
227- t.Fatal("Close deadlocked in delivery")
228- }
229-}
230-
231-func TestClipboardCloseWhileDeliveryBlocks(t *testing.T) {
232- c, d, server := clipboardFixture(t)
233- offer := core.NewDataOffer(d.Context())
234- offer.SetID(82)
235- d.Context().Register(offer)
236- c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer})
237- c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: mimePlain})
238- c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer})
239- entered := make(chan struct{})
240- unblock := make(chan struct{})
241- callbackDone := make(chan struct{})
242- if err := c.ReadTextAsync(context.Background(), 32, func([]byte, error) {
243- close(entered)
244- <-unblock
245- close(callbackDone)
246- }); err != nil {
247- t.Fatal(err)
248- }
249- clipboardFinishRead(t, server)
250- select {
251- case <-entered:
252- case <-time.After(time.Second):
253- t.Fatal("delivery not called")
254- }
255- done := make(chan struct{})
256- go func() { c.Close(); close(done) }()
257- select {
258- case <-done:
259- case <-time.After(time.Second):
260- t.Fatal("Close blocked on delivery")
261- }
262- close(unblock)
263- select {
264- case <-callbackDone:
265- case <-time.After(time.Second):
266- t.Fatal("callback did not finish")
267- }
268-}
269-
270-// Consume receive and close the transferred write FD to complete the read.
271-func clipboardFinishRead(t *testing.T, server *net.UnixConn) {
272- t.Helper()
273- buf, oob := make([]byte, 256), make([]byte, 128)
274- _ = server.SetReadDeadline(time.Now().Add(time.Second))
275- _, n, _, _, err := server.ReadMsgUnix(buf, oob)
276- if err != nil {
277- t.Fatal(err)
278- }
279- msgs, err := unix.ParseSocketControlMessage(oob[:n])
280- if err != nil {
281- t.Fatal(err)
282- }
283- for _, m := range msgs {
284- fds, err := unix.ParseUnixRights(&m)
285- if err != nil {
286- t.Fatal(err)
287- }
288- for _, fd := range fds {
289- _, _ = unix.Write(fd, []byte("ok"))
290- _ = unix.Close(fd)
291- }
292- }
293-}
D internal/platform/wayland/clipboard_test.go +0 -127
@@ -1,127 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "context"
7- "errors"
8- "io"
9- "net"
10- "os"
11- "testing"
12- "time"
13-
14- "github.com/bnema/nefergui/internal/edit"
15- "github.com/bnema/wlturbo"
16- "github.com/bnema/wlturbo/protocol/core"
17- "golang.org/x/sys/unix"
18-)
19-
20-func clipboardFixture(t *testing.T) (*Clipboard, *wlturbo.Display, *net.UnixConn) {
21- t.Helper()
22- ca, server := inputPair(t)
23- d, err := wlturbo.ConnectFromConn(ca)
24- if err != nil {
25- t.Fatal(err)
26- }
27- t.Cleanup(func() { _ = d.Close() })
28- inputRequest(t, server)
29- ctx := d.Context()
30- manager := core.NewDataDeviceManager(ctx)
31- if err = d.Registry().Bind(1, core.DataDeviceManagerInterface, 3, manager); err != nil {
32- t.Fatal(err)
33- }
34- inputRequest(t, server)
35- seat := core.NewSeat(ctx)
36- seat.SetID(20)
37- ctx.Register(seat)
38- device, err := manager.GetDataDevice(seat)
39- if err != nil {
40- t.Fatal(err)
41- }
42- inputRequest(t, server)
43- c := &Clipboard{manager: manager, device: device, serial: func() uint32 { return 42 }, pending: make(map[*core.DataOffer]map[string]bool), offers: make(map[uint32]*core.DataOffer)}
44- c.post = func(e ClipboardEvent) { c.ApplyClipboard(e) }
45- return c, d, server
46-}
47-func TestClipboardReadOverSocketpair(t *testing.T) {
48- c, d, server := clipboardFixture(t)
49- offer := core.NewDataOffer(d.Context())
50- offer.SetID(30)
51- d.Context().Register(offer)
52- c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer})
53- for _, m := range []string{mimePlain, mimeX11, mimeUTF8} {
54- c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: m})
55- }
56- c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer})
57- if c.bestMIME() != mimeUTF8 {
58- t.Fatal("MIME priority")
59- }
60- results := make(chan ClipboardResult, 2)
61- // The peer reads receive's SCM_RIGHTS and writes to the transferred pipe.
62- go func() {
63- b := make([]byte, 512)
64- oob := make([]byte, 128)
65- _ = server.SetReadDeadline(time.Now().Add(time.Second))
66- _, n, _, _, err := server.ReadMsgUnix(b, oob)
67- if err != nil {
68- results <- ClipboardResult{Err: err}
69- return
70- }
71- messages, err := unix.ParseSocketControlMessage(oob[:n])
72- if err != nil {
73- results <- ClipboardResult{Err: err}
74- return
75- }
76- for _, m := range messages {
77- fd, e := unix.ParseUnixRights(&m)
78- if e != nil || len(fd) == 0 {
79- continue
80- }
81- f := os.NewFile(uintptr(fd[0]), "offer-write")
82- _, e = io.WriteString(f, "hello")
83- _ = f.Close()
84- if e != nil {
85- results <- ClipboardResult{Err: e}
86- }
87- return
88- }
89- results <- ClipboardResult{Err: errors.New("missing pipe FD")}
90- }()
91- if err := c.ReadTextAsync(context.Background(), edit.MaxClipboardBytes, func(b []byte, e error) { results <- ClipboardResult{Data: b, Err: e} }); err != nil {
92- t.Fatal(err)
93- }
94- select {
95- case result := <-results:
96- if result.Err != nil || string(result.Data) != "hello" {
97- t.Fatalf("result: %+v", result)
98- }
99- case <-time.After(2 * time.Second):
100- t.Fatal("read blocked")
101- }
102-}
103-func TestClipboardBoundAndCancel(t *testing.T) {
104- r, w, err := os.Pipe()
105- if err != nil {
106- t.Fatal(err)
107- }
108- defer w.Close()
109- results := make(chan ClipboardResult, 1)
110- go func() { data, e := io.ReadAll(io.LimitReader(r, 5)); results <- ClipboardResult{data, e} }()
111- _, _ = w.Write([]byte("123456"))
112- select {
113- case result := <-results:
114- if len(result.Data) != 5 {
115- t.Fatal(result)
116- }
117- case <-time.After(time.Second):
118- t.Fatal("limit blocked")
119- }
120- state := edit.State{}
121- if ok, _ := state.PasteAsync([]byte{0xff}, nil); ok {
122- t.Fatal("accepted invalid UTF-8")
123- }
124- if ok, _ := state.PasteAsync(make([]byte, edit.MaxClipboardBytes+1), nil); ok {
125- t.Fatal("accepted oversized paste")
126- }
127-}
D internal/platform/wayland/connection.go +0 -484
@@ -1,484 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "context"
7- "errors"
8- "fmt"
9- "net"
10- "os"
11- "sort"
12- "sync"
13- "sync/atomic"
14- "time"
15-
16- "github.com/bnema/wlturbo"
17- "github.com/bnema/wlturbo/protocol/core"
18- "github.com/bnema/wlturbo/protocol/extsessionlock"
19-)
20-
21-// Connection owns one socket, one reader and one seat. All NewWindow, Apply,
22-// Outputs, AcquireLock and Close calls belong to the same UI owner goroutine.
23-// Reader callbacks post immutable events; they never mutate owner window state.
24-type Connection struct {
25- Display *wlturbo.Display
26- events chan Event
27- stop chan struct{}
28- done chan struct{}
29- once sync.Once
30- started atomic.Bool
31- windows map[uint32]*Window
32- routes sync.Map // immutable surface->window identities published by owner
33- outputs map[uint32]Output
34- seat *Window // transport-only seat host, never a rendering window
35- lockManager *extsessionlock.ExtSessionLockManager
36- lock *extsessionlock.ExtSessionLock
37- lockFinish bool // finished event applied
38- lockDone bool // locked event applied
39- held []Event // lock/output events kept by drainStartup(true); owner only
40- closed bool
41-
42- // Reader pause gate. The owner pauses the reader to register handlers on
43- // freshly created objects (whose first events may arrive immediately) or to
44- // run its own roundtrip. While paused, post parks events instead of
45- // blocking, so neither side can wait on the bounded queue.
46- socket net.Conn
47- gate sync.Mutex
48- pausing bool
49- pauseSig chan struct{} // closed when a pause is requested
50- parked []Event
51- pauseDepth int // owner goroutine only
52- pauseAck chan struct{}
53- resume chan struct{}
54-}
55-
56-// Output identifies the registry lifetime, not merely a reusable monitor name.
57-type Output struct {
58- Global uint32
59- Name string
60- Scale int32
61- proxy *core.Output
62-}
63-
64-func ConnectConnection(ctx context.Context, name string) (res *Connection, err error) {
65- if ctx == nil {
66- return nil, errors.New("nil context")
67- }
68- if err := ctx.Err(); err != nil {
69- return nil, err
70- }
71- path, err := waylandSocketPath(name)
72- if err != nil {
73- return nil, err
74- }
75- socket, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "unix", path)
76- if err != nil {
77- return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
78- }
79- display, err := wlturbo.ConnectFromConn(socket)
80- if err != nil {
81- return nil, err
82- }
83- c := &Connection{Display: display, socket: socket, events: make(chan Event, 256), stop: make(chan struct{}), windows: make(map[uint32]*Window), outputs: make(map[uint32]Output), pauseSig: make(chan struct{}), pauseAck: make(chan struct{}, 1), resume: make(chan struct{}, 1)}
84- watch := context.AfterFunc(ctx, func() { _ = display.Close() })
85- defer func() {
86- if !watch() && err == nil {
87- err = ctx.Err()
88- }
89- if err != nil {
90- _ = c.Close()
91- res = nil
92- if ctx.Err() != nil {
93- err = errors.Join(ctx.Err(), err)
94- }
95- }
96- }()
97- if err = display.Roundtrip(); err != nil {
98- return nil, err
99- }
100- c.seat = &Window{Display: display, connection: c, events: c.events, readerStop: c.stop}
101- if err = c.seat.BindSeat(); err != nil {
102- return nil, err
103- }
104- display.Registry().AddGlobalHandler(c)
105- display.Registry().AddGlobalRemoveHandler(c)
106- for name, g := range display.Registry().GetGlobals() {
107- if g.Interface == core.OutputInterface {
108- if err = c.bindOutput(name, g.Version); err != nil {
109- return nil, err
110- }
111- }
112- }
113- if err = display.Roundtrip(); err != nil {
114- return nil, err
115- }
116- if err = c.drainStartup(false); err != nil {
117- return nil, err
118- }
119- return c, nil
120-}
121-
122-func (c *Connection) bindOutput(global, version uint32) error {
123- if version < 4 {
124- return &CapabilityError{Name: core.OutputInterface, Cause: fmt.Errorf("output names require version 4, announced %d", version)}
125- }
126- out := core.NewOutput(c.Display.Context())
127- if err := c.Display.Registry().Bind(global, core.OutputInterface, 4, out); err != nil {
128- c.Display.Context().Unregister(out)
129- return err
130- }
131- c.outputs[global] = Output{Global: global, Scale: 1, proxy: out}
132- out.OnName(func(name string) { c.post(Event{Kind: OutputNamed, Output: Output{Global: global, Name: name}}) })
133- out.OnScale(func(scale int32) { c.post(Event{Kind: OutputScale, Output: Output{Global: global, Scale: scale}}) })
134- out.OnDone(func() { c.post(Event{Kind: OutputAdded, Output: Output{Global: global}}) })
135- return nil
136-}
137-
138-func (c *Connection) HandleRegistryGlobal(e wlturbo.RegistryGlobalEvent) {
139- if e.Interface == core.OutputInterface {
140- c.post(Event{Kind: OutputGlobal, Output: Output{Global: e.Name}, Version: e.Version})
141- }
142-}
143-func (c *Connection) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) {
144- c.post(Event{Kind: OutputRemoved, Output: Output{Global: e.Name}})
145-}
146-func (c *Connection) post(ev Event) {
147- c.gate.Lock()
148- if c.pausing {
149- c.parked = append(c.parked, ev)
150- c.gate.Unlock()
151- return
152- }
153- sig := c.pauseSig
154- c.gate.Unlock()
155- select {
156- case c.events <- ev:
157- case <-c.stop:
158- CloseEventFD(ev)
159- case <-sig:
160- c.gate.Lock()
161- c.parked = append(c.parked, ev)
162- c.gate.Unlock()
163- }
164-}
165-
166-// pauseReader stops the reader between two Dispatch calls and returns once it
167-// is parked. The nested calls of one owner goroutine share the first pause;
168-// the returned func ends it. Without a running reader it does nothing.
169-func (c *Connection) pauseReader() (resume func()) {
170- if !c.started.Load() || c.done == nil {
171- return func() {}
172- }
173- c.pauseDepth++
174- if c.pauseDepth > 1 {
175- return func() { c.pauseDepth-- }
176- }
177- c.gate.Lock()
178- c.pausing = true
179- close(c.pauseSig)
180- c.gate.Unlock()
181- if c.socket != nil {
182- _ = c.socket.SetReadDeadline(time.Unix(1, 0)) // wakes a blocked read
183- }
184- select {
185- case <-c.pauseAck:
186- case <-c.done:
187- }
188- if c.socket != nil {
189- _ = c.socket.SetReadDeadline(time.Time{})
190- }
191- return func() {
192- c.pauseDepth--
193- if c.pauseDepth > 0 {
194- return
195- }
196- c.gate.Lock()
197- c.pausing = false
198- c.pauseSig = make(chan struct{})
199- c.gate.Unlock()
200- select {
201- case c.resume <- struct{}{}:
202- default:
203- }
204- }
205-}
206-
207-// flushParked re-posts events held while paused; reader goroutine only.
208-func (c *Connection) flushParked() {
209- c.gate.Lock()
210- held := c.parked
211- c.parked = nil
212- c.gate.Unlock()
213- for _, ev := range held {
214- c.post(ev)
215- }
216-}
217-
218-// ErrLockFinished is wrapped by platform errors when the compositor refused or
219-// ended the session lock (ext_session_lock_v1.finished).
220-var ErrLockFinished = errors.New("wayland: session lock finished")
221-
222-// holdDuringSetup reports events the lock owner must see through its own event
223-// handling: lock state and output lifetimes. They are never applied by the
224-// startup drain, which would hide them from the owner.
225-func holdDuringSetup(k EventKind) bool {
226- switch k {
227- case LockAcquired, LockFinished, OutputGlobal, OutputNamed, OutputScale, OutputAdded, OutputRemoved:
228- return true
229- }
230- return false
231-}
232-
233-// drainStartup applies queued events before the reader starts. With hold set
234-// (window creation after the lock exists) lock and output events are kept, in
235-// order, for TakeHeld instead of being applied; a held `finished` fails setup.
236-func (c *Connection) drainStartup(hold bool) error {
237- for {
238- select {
239- case ev := <-c.events:
240- if hold && ev.Window == nil && holdDuringSetup(ev.Kind) {
241- c.held = append(c.held, ev)
242- if ev.Kind == LockFinished {
243- c.lockFinish = true // Close then sends the plain destroy
244- return ErrLockFinished
245- }
246- continue
247- }
248- if err := c.Apply(ev); err != nil {
249- return err
250- }
251- default:
252- return nil
253- }
254- }
255-}
256-
257-// TakeHeld returns the lock and output events kept during window setup, oldest
258-// first. The owner feeds them to its normal event handling right after it
259-// starts the reader, so they precede everything the reader posts later.
260-func (c *Connection) TakeHeld() []Event {
261- held := c.held
262- c.held = nil
263- return held
264-}
265-
266-// Outputs returns a copy of owner-applied output lifetimes. Call after applying
267-// OutputAdded/Removed events; names and scales are never read from the reader.
268-func (c *Connection) Outputs() []Output {
269- result := make([]Output, 0, len(c.outputs))
270- for _, out := range c.outputs {
271- if out.Name != "" {
272- result = append(result, out)
273- }
274- }
275- sort.Slice(result, func(i, j int) bool { return result[i].Global < result[j].Global })
276- return result
277-}
278-
279-// AcquireLock creates the sole lock object for this connection. Locked and
280-// Finished are transport events, not an authentication or unlock policy.
281-func (c *Connection) AcquireLock() error {
282- if c.closed || c.lock != nil {
283- return errors.New("connection closed or lock already acquired")
284- }
285- defer c.pauseReader()() // locked/finished may follow the request immediately
286- if c.lockManager == nil {
287- manager := extsessionlock.NewExtSessionLockManager(c.Display.Context())
288- if _, err := c.Display.Registry().BindNegotiated(extsessionlock.ExtSessionLockManagerInterface, 1, manager); err != nil {
289- c.Display.Context().Unregister(manager)
290- return &CapabilityError{Name: extsessionlock.ExtSessionLockManagerInterface, Cause: err}
291- }
292- c.lockManager = manager
293- }
294- lock, err := c.lockManager.Lock()
295- if err != nil {
296- return err
297- }
298- c.lock = lock
299- lock.OnLocked(func() { c.post(Event{Kind: LockAcquired}) })
300- lock.OnFinished(func() { c.post(Event{Kind: LockFinished}) })
301- if !c.started.Load() {
302- // The compositor answers with locked or finished immediately; queue it
303- // now so window setup can hold it for the owner (see drainStartup).
304- return c.Display.Roundtrip()
305- }
306- return nil
307-}
308-
309-// Locked and Finished report the owner-applied lock state.
310-func (c *Connection) Locked() bool { return c.lockDone && !c.lockFinish }
311-func (c *Connection) Finished() bool { return c.lockFinish }
312-
313-// UnlockAndDestroy sends unlock_and_destroy and roundtrips so the compositor
314-// has processed it before the caller closes anything. It is only valid after
315-// the locked event; the platform supplies no authorization policy. The reader
316-// is paused for the roundtrip, so events it posts are parked, never lost.
317-func (c *Connection) UnlockAndDestroy() error {
318- if c.lock == nil {
319- return errors.New("no session lock")
320- }
321- if !c.Locked() {
322- return errors.New("unlock before the locked event")
323- }
324- defer c.pauseReader()()
325- if err := c.lock.UnlockAndDestroy(); err != nil {
326- return err
327- }
328- c.lock, c.lockDone = nil, false
329- return c.Display.Roundtrip()
330-}
331-
332-func (c *Connection) StartReader() <-chan Event {
333- c.once.Do(func() {
334- c.started.Store(true)
335- for _, w := range c.windows {
336- w.readerStarted.Store(true)
337- }
338- c.done = make(chan struct{})
339- go func() {
340- defer close(c.done)
341- for {
342- select {
343- case <-c.stop:
344- return
345- default:
346- }
347- c.gate.Lock()
348- paused := c.pausing
349- c.gate.Unlock()
350- if paused {
351- c.pauseAck <- struct{}{}
352- select {
353- case <-c.resume:
354- c.flushParked()
355- continue
356- case <-c.stop:
357- return
358- }
359- }
360- err := c.Display.Dispatch()
361- if errors.Is(err, os.ErrDeadlineExceeded) {
362- continue // a pause request interrupted the read
363- }
364- if err != nil {
365- c.post(Event{Kind: TransportError, Err: err})
366- return
367- }
368- }
369- }()
370- })
371- return c.events
372-}
373-func (c *Connection) Events() <-chan Event { return c.events }
374-
375-// Apply handles connection events or routes a tagged event to its window.
376-func (c *Connection) Apply(ev Event) error {
377- if ev.Window != nil {
378- return ev.Window.Apply(ev)
379- }
380- switch ev.Kind {
381- case OutputGlobal:
382- if _, exists := c.outputs[ev.Output.Global]; !exists {
383- defer c.pauseReader()() // name/scale/done follow the bind
384- return c.bindOutput(ev.Output.Global, ev.Version)
385- }
386- case OutputNamed, OutputScale:
387- out, ok := c.outputs[ev.Output.Global]
388- if !ok {
389- return nil
390- }
391- if ev.Kind == OutputNamed {
392- out.Name = ev.Output.Name
393- } else if ev.Output.Scale > 0 {
394- out.Scale = ev.Output.Scale
395- }
396- c.outputs[out.Global] = out
397- case OutputAdded:
398- case OutputRemoved:
399- if out, ok := c.outputs[ev.Output.Global]; ok {
400- _ = out.proxy.Release()
401- delete(c.outputs, ev.Output.Global)
402- }
403- case SeatCapabilities, SeatRemoved:
404- oldPointer := c.seat.Pointer
405- defer c.pauseReader()() // keymap/capability events follow get_keyboard
406- if ev.Kind == SeatRemoved || (ev.Capabilities&1 == 0 && oldPointer != nil) {
407- // Shape devices die before the wl_pointer they were created from
408- // is released; their enter serials belonged to that pointer.
409- for _, w := range c.windows {
410- w.dropCursorDevice()
411- }
412- }
413- if err := c.seat.ApplySeat(ev); err != nil {
414- return err
415- }
416- for _, w := range c.windows {
417- if oldPointer != c.seat.Pointer {
418- w.dropCursorDevice()
419- w.queueInput([]Input{{Kind: "leave"}})
420- }
421- w.Seat, w.Pointer, w.WLKeyboard = c.seat.Seat, c.seat.Pointer, c.seat.WLKeyboard
422- if w.LockSurface != nil {
423- w.Seat = nil
424- }
425- w.pointerGen = c.seat.pointerGen
426- if ev.Kind == SeatRemoved || ev.Capabilities&2 == 0 {
427- w.ownerFocusOut()
428- }
429- }
430- case InputFocusOut:
431- for _, w := range c.windows {
432- w.ownerFocusOut()
433- }
434- case TransportError:
435- return ev.Err
436- case LockAcquired:
437- c.lockDone = true
438- case LockFinished:
439- c.lockFinish = true
440- default:
441- return fmt.Errorf("unrouted connection event %d", ev.Kind)
442- }
443- return nil
444-}
445-
446-func (c *Connection) Close() error {
447- if c == nil || c.closed {
448- return nil
449- }
450- c.closed = true
451- close(c.stop)
452- for _, w := range c.windows {
453- _ = w.Close()
454- }
455- if c.lock != nil && c.lockFinish {
456- // Refused, or ended after locked: the compositor ended the lock, so a
457- // plain destroy is valid (unlock_and_destroy already cleared c.lock).
458- _ = c.lock.Destroy()
459- }
460- if c.lockManager != nil {
461- _ = c.lockManager.Destroy()
462- }
463- err := c.Display.Close() // closing an acquired owner never requests unlock
464- if c.done != nil {
465- <-c.done
466- }
467- if c.seat != nil {
468- _ = c.seat.releaseSeat()
469- }
470- for len(c.events) > 0 {
471- CloseEventFD(<-c.events)
472- }
473- for _, ev := range c.held {
474- CloseEventFD(ev)
475- }
476- c.held = nil
477- c.gate.Lock()
478- for _, ev := range c.parked {
479- CloseEventFD(ev)
480- }
481- c.parked = nil
482- c.gate.Unlock()
483- return err
484-}
D internal/platform/wayland/connection_input.go +0 -205
@@ -1,205 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "sync"
7- "syscall"
8-
9- "github.com/bnema/wlturbo/protocol/core"
10- "github.com/bnema/wlturbo/wl"
11- "golang.org/x/sys/unix"
12-)
13-
14-// A cache belongs to exactly one keyboard generation. Dup/replace/close share
15-// its FD mutex, not a window-state mutex, so capability loss cannot close an FD
16-// while a reader callback duplicates it. Retired callbacks are inert.
17-type keyboardMapCache struct {
18- mu sync.Mutex
19- fd int
20- closed bool
21-}
22-
23-func (m *keyboardMapCache) replace(fd int) {
24- m.mu.Lock()
25- defer m.mu.Unlock()
26- if m.closed {
27- _ = syscall.Close(fd)
28- return
29- }
30- if m.fd >= 0 {
31- _ = syscall.Close(m.fd)
32- }
33- m.fd = fd
34-}
35-func (m *keyboardMapCache) duplicate() (int, error) {
36- m.mu.Lock()
37- defer m.mu.Unlock()
38- if m.closed || m.fd < 0 {
39- return -1, nil
40- }
41- // Close-on-exec: the map must not leak into any child process.
42- return unix.FcntlInt(uintptr(m.fd), unix.F_DUPFD_CLOEXEC, 0)
43-}
44-func (m *keyboardMapCache) close() {
45- m.mu.Lock()
46- defer m.mu.Unlock()
47- if !m.closed && m.fd >= 0 {
48- _ = syscall.Close(m.fd)
49- }
50- m.fd = -1
51- m.closed = true
52-}
53-
54-func (c *Connection) route(id uint32, ev Event) {
55- if value, ok := c.routes.Load(id); ok {
56- ev.Window = value.(*Window)
57- c.post(ev)
58- } else {
59- CloseEventFD(ev)
60- }
61-}
62-
63-// Focus and frame accumulators below are exclusively reader-owned. They carry
64-// surface IDs, never pointers to mutable UI/window state.
65-func (w *Window) watchSharedPointer(p *core.Pointer) {
66- c := w.connection
67- w.pointerGen++
68- generation, version := w.pointerGen, w.seatVersion
69- var focus uint32
70- var x, y float64
71- var frame []Input
72- var source uint32
73- flush := func() {
74- if len(frame) > 0 {
75- c.route(focus, Event{Kind: InputFrame, Inputs: frame})
76- frame = nil
77- }
78- source = 0
79- }
80- add := func(in Input) {
81- in.AxisSource = source
82- frame = append(frame, in)
83- if version < 5 {
84- flush()
85- }
86- }
87- p.OnEnter(func(serial, id uint32, px, py wl.Fixed) {
88- flush()
89- focus = id
90- x, y = px.Float64(), py.Float64()
91- add(Input{Kind: "motion", X: x, Y: y, Enter: true, Serial: serial, PointerGen: generation})
92- })
93- p.OnLeave(func(_ uint32, id uint32) {
94- if focus == id {
95- add(Input{Kind: "leave"})
96- flush()
97- focus = 0
98- }
99- })
100- p.OnMotion(func(_ uint32, px, py wl.Fixed) {
101- if focus != 0 {
102- x, y = px.Float64(), py.Float64()
103- add(Input{Kind: "motion", X: x, Y: y})
104- }
105- })
106- p.OnButton(func(serial, _, button, state uint32) {
107- if focus != 0 {
108- kind := "release"
109- if state == 1 {
110- kind = "press"
111- }
112- add(Input{Kind: kind, X: x, Y: y, Button: button, Serial: serial})
113- }
114- })
115- p.OnAxisSource(func(value uint32) { source = value })
116- p.OnAxis(func(_, axis uint32, value wl.Fixed) {
117- if focus != 0 {
118- in := Input{Kind: "axis", X: x, Y: y, Axis: axis}
119- if axis == 0 {
120- in.DY = value.Float64()
121- } else {
122- in.DX = value.Float64()
123- }
124- add(in)
125- }
126- })
127- p.OnAxisValue120(func(axis uint32, value int32) {
128- if focus != 0 {
129- add(Input{Kind: "axis", X: x, Y: y, Axis: axis, Value120: value})
130- }
131- })
132- p.OnAxisStop(func(_, axis uint32) {
133- if focus != 0 {
134- add(Input{Kind: "axis", X: x, Y: y, Axis: axis, AxisStop: true})
135- }
136- })
137- p.OnFrame(flush)
138-}
139-
140-func (w *Window) watchSharedKeyboard(k *core.Keyboard) {
141- c := w.connection
142- var focus uint32
143- cache := &keyboardMapCache{fd: -1}
144- if w.keymapCache != nil {
145- w.keymapCache.close() // one live cache per keyboard generation
146- }
147- w.keymapCache = cache
148- var format, size uint32
149- var mods [4]uint32
150- var rate, delay int32
151- // Keep one reader-owned duplicate to initialize windows created on hotplug.
152- // Its lifetime ends when this keyboard proxy or the connection is released.
153- sendMap := func(id uint32) {
154- fd, err := cache.duplicate()
155- if err != nil {
156- c.post(Event{Kind: TransportError, Err: err})
157- return
158- }
159- if fd >= 0 {
160- c.route(id, Event{Kind: InputKeymap, FD: fd, Format: format, Size: size})
161- }
162- }
163- k.OnKeymap(func(f uint32, fd *wl.OwnedFD, n uint32) {
164- next, err := fd.Take()
165- if err != nil {
166- c.post(Event{Kind: TransportError, Err: err})
167- return
168- }
169- cache.replace(next)
170- format, size = f, n
171- if focus != 0 {
172- sendMap(focus)
173- }
174- })
175- k.OnEnter(func(serial, id uint32, _ []byte) {
176- focus = id
177- sendMap(id)
178- c.route(id, Event{Kind: InputRepeatInfo, Rate: rate, Delay: delay})
179- c.route(id, Event{Kind: InputModifiers, Mods: mods})
180- c.route(id, Event{Kind: InputFocusIn, Serial: serial})
181- })
182- k.OnLeave(func(_, id uint32) {
183- c.route(id, Event{Kind: InputFocusOut})
184- if focus == id {
185- focus = 0
186- }
187- })
188- k.OnKey(func(serial, _, key, state uint32) {
189- if focus != 0 {
190- c.route(focus, Event{Kind: InputKey, Serial: serial, Code: key, Pressed: state == 1})
191- }
192- })
193- k.OnModifiers(func(_, depressed, latched, locked, group uint32) {
194- mods = [4]uint32{depressed, latched, locked, group}
195- if focus != 0 {
196- c.route(focus, Event{Kind: InputModifiers, Mods: mods})
197- }
198- })
199- k.OnRepeatInfo(func(r, d int32) {
200- rate, delay = r, d
201- if focus != 0 {
202- c.route(focus, Event{Kind: InputRepeatInfo, Rate: r, Delay: d})
203- }
204- })
205-}
D internal/platform/wayland/connection_test.go +0 -581
@@ -1,581 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "context"
7- "encoding/binary"
8- "errors"
9- "fmt"
10- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
11- "golang.org/x/sys/unix"
12- "net"
13- "os"
14- "strings"
15- "syscall"
16- "testing"
17- "time"
18-
19- "github.com/bnema/wlturbo"
20- "github.com/bnema/wlturbo/protocol/core"
21- "github.com/bnema/wlturbo/protocol/cursorshape"
22-)
23-
24-func TestSharedConnectionKeyboardRoutesOnlyFocusedWindow(t *testing.T) {
25- ca, server := inputPair(t)
26- d, err := wlturbo.ConnectFromConn(ca)
27- if err != nil {
28- t.Fatal(err)
29- }
30- defer d.Close()
31- inputRequest(t, server)
32- c := &Connection{Display: d, events: make(chan Event, 32), stop: make(chan struct{}), windows: make(map[uint32]*Window)}
33- host := &Window{connection: c, seatVersion: 9}
34- seat := core.NewSeat(d.Context())
35- if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil {
36- t.Fatal(err)
37- }
38- inputRequest(t, server)
39- keyboard, err := seat.GetKeyboard()
40- if err != nil {
41- t.Fatal(err)
42- }
43- inputRequest(t, server)
44- first, second := &Window{connection: c}, &Window{connection: c}
45- c.routes.Store(uint32(30), first)
46- c.routes.Store(uint32(31), second)
47- host.watchSharedKeyboard(keyboard)
48- // enter includes a length-prefixed empty key array, then key down.
49- for _, msg := range [][]byte{inputFrame(keyboard.ID(), 1, 1, 30, 0), inputFrame(keyboard.ID(), 3, 2, 0, 30, 1), inputFrame(keyboard.ID(), 2, 3, 30), inputFrame(keyboard.ID(), 1, 4, 31, 0), inputFrame(keyboard.ID(), 3, 5, 0, 48, 1)} {
50- inputSend(t, server, msg)
51- if err := d.Dispatch(); err != nil {
52- t.Fatal(err)
53- }
54- }
55- var keys []Event
56- for len(c.events) > 0 {
57- ev := <-c.events
58- if ev.Kind == InputKey {
59- keys = append(keys, ev)
60- }
61- }
62- if len(keys) != 2 || keys[0].Window != first || keys[0].Code != 30 || keys[1].Window != second || keys[1].Code != 48 {
63- t.Fatalf("keys: %+v", keys)
64- }
65-}
66-
67-func TestHarnessConnectionLock(t *testing.T) {
68- out, _ := runLayerHarness(t, "TestHarnessConnectionLockClient")
69- for _, want := range []string{"LOCK acquired", "LOCK exact configure", "LOCK shared display", "LOCK sibling survived", "LOCK unlock", "LOCK manager reused", "LOCK named layer sibling"} {
70- if !strings.Contains(out, want) {
71- t.Fatalf("missing %q in %s", want, out)
72- }
73- }
74-}
75-func TestHarnessConnectionLockClient(t *testing.T) {
76- harnessChild(t)
77- ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
78- defer cancel()
79- c, err := ConnectConnection(ctx, "")
80- if err != nil {
81- t.Fatal(err)
82- }
83- defer c.Close()
84- outputs := c.Outputs()
85- if len(outputs) != 1 {
86- t.Fatalf("outputs %+v", outputs)
87- }
88- if err := c.AcquireLock(); err != nil {
89- t.Fatal(err)
90- }
91- events := c.StartReader()
92- wait := func(want EventKind) {
93- t.Helper()
94- for {
95- select {
96- case ev := <-events:
97- if err := c.Apply(ev); err != nil {
98- t.Fatal(err)
99- }
100- if ev.Kind == want {
101- return
102- }
103- case <-ctx.Done():
104- t.Fatal("event timeout")
105- }
106- }
107- }
108- wait(LockAcquired)
109- fmt.Println("LOCK acquired")
110- first, err := c.NewWindow(ctx, 1, 1, false, SurfaceOptions{Lock: &LockOptions{Output: outputs[0].Global}})
111- if err != nil {
112- t.Fatal(err)
113- }
114- for !first.Configured || !first.FeedbackDone || first.MainDevice == 0 || len(first.Tranches) == 0 {
115- select {
116- case ev := <-events:
117- if err := c.Apply(ev); err != nil {
118- t.Fatal(err)
119- }
120- case <-ctx.Done():
121- t.Fatal("lock configure timeout")
122- }
123- }
124- if first.Width != 400 || first.Height != 300 || first.LockSurface == nil || first.XdgSurface != nil || first.LayerSurface != nil {
125- t.Fatalf("lock geometry/roles: %dx%d", first.Width, first.Height)
126- }
127- fmt.Println("LOCK exact configure")
128- // An ordinary sibling shares the transport, but cannot acquire another lock.
129- second, err := c.NewWindow(ctx, 20, 20, false, SurfaceOptions{})
130- if err != nil {
131- t.Fatal(err)
132- }
133- if first.Display != second.Display || first.Display != c.Display || first.Seat != nil {
134- t.Fatal("independent display or lock exposed clipboard/IME seat")
135- }
136- fmt.Println("LOCK shared display")
137- if err := first.Close(); err != nil {
138- t.Fatal(err)
139- }
140- if second.Display == nil || c.closed {
141- t.Fatal("window close destroyed shared connection")
142- }
143- if err := second.SetTitle("sibling survives"); err != nil {
144- t.Fatal(err)
145- }
146- fmt.Println("LOCK sibling survived")
147- if err := c.UnlockAndDestroy(); err != nil {
148- t.Fatal(err)
149- }
150- fmt.Println("LOCK unlock")
151- manager := c.lockManager
152- for range 2 {
153- if err := c.AcquireLock(); err != nil {
154- t.Fatal(err)
155- }
156- wait(LockAcquired)
157- if c.lockManager != manager {
158- t.Fatal("lock manager leaked across cycles")
159- }
160- if err := c.UnlockAndDestroy(); err != nil {
161- t.Fatal(err)
162- }
163- }
164- fmt.Println("LOCK manager reused")
165- layer, err := c.NewWindow(ctx, 20, 20, true, SurfaceOptions{Layer: &LayerOptions{Output: outputs[0].Name, Layer: LayerTop, Namespace: "named-sibling"}})
166- if err != nil {
167- t.Fatal(err)
168- }
169- for !layer.Configured || !layer.FeedbackDone {
170- select {
171- case ev := <-events:
172- if err := c.Apply(ev); err != nil {
173- t.Fatal(err)
174- }
175- case <-ctx.Done():
176- t.Fatal("named sibling timeout")
177- }
178- }
179- if layer.layerOutputGlobal != outputs[0].Global {
180- t.Fatal("named sibling selected wrong output")
181- }
182- fmt.Println("LOCK named layer sibling")
183-}
184-
185-func TestOwnerFocusResetWithFullReaderQueue(t *testing.T) {
186- c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{1: {}}}
187- for range cap(c.events) {
188- c.events <- Event{Kind: LockAcquired}
189- }
190- done := make(chan error, 1)
191- go func() { done <- c.Apply(Event{Kind: InputFocusOut}) }()
192- select {
193- case err := <-done:
194- if err != nil {
195- t.Fatal(err)
196- }
197- case <-time.After(time.Second):
198- close(c.stop)
199- t.Fatal("owner reset self-posted into full reader queue")
200- }
201- if len(c.events) != cap(c.events) {
202- t.Fatal("owner reset consumed reader events")
203- }
204- inputs := c.windows[1].DrainInput(nil)
205- if len(inputs) != 1 || inputs[0].Kind != "focus-out" {
206- t.Fatalf("inputs %+v", inputs)
207- }
208-}
209-
210-func TestKeyboardMapCacheRetiresEveryGeneration(t *testing.T) {
211- for range 4 {
212- f, err := os.CreateTemp(t.TempDir(), "keymap")
213- if err != nil {
214- t.Fatal(err)
215- }
216- fd, err := syscall.Dup(int(f.Fd()))
217- if err != nil {
218- t.Fatal(err)
219- }
220- f.Close()
221- cache := &keyboardMapCache{fd: -1}
222- cache.replace(fd)
223- duplicate, err := cache.duplicate()
224- if err != nil || duplicate < 0 {
225- t.Fatalf("duplicate %d %v", duplicate, err)
226- }
227- if flags, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil || flags&unix.FD_CLOEXEC == 0 {
228- t.Fatalf("delivered keymap duplicate is inheritable: flags %d err %v", flags, err)
229- }
230- cache.close()
231- cache.close()
232- if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, syscall.EBADF) {
233- t.Fatalf("retired map FD still open: %v", err)
234- }
235- if _, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil {
236- t.Fatal("retired source closed delivered duplicate")
237- }
238- syscall.Close(duplicate)
239- if got, err := cache.duplicate(); got != -1 || err != nil {
240- t.Fatal("retired callback duplicated map")
241- }
242- }
243-}
244-
245-func TestFeedbackReadinessWaitsForCompleteEvent(t *testing.T) {
246- w := &Window{Configured: true}
247- if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 1}); err != nil {
248- t.Fatal(err)
249- }
250- if err := w.Apply(Event{Kind: FeedbackTranche, Tranche: []linuxdmabuf.FormatEntry{{}}}); err != nil {
251- t.Fatal(err)
252- }
253- if w.FeedbackDone {
254- t.Fatal("partial feedback treated as complete")
255- }
256- if err := w.Apply(Event{Kind: FeedbackFormats, Formats: []linuxdmabuf.FormatEntry{{}}}); err != nil {
257- t.Fatal(err)
258- }
259- if w.FeedbackDone {
260- t.Fatal("table alone treated as complete")
261- }
262- if err := w.Apply(Event{Kind: FeedbackComplete}); err != nil || !w.FeedbackDone {
263- t.Fatal("done not applied")
264- }
265-}
266-
267-// sharedSeat builds a Connection whose seat host is bound over a socketpair.
268-func sharedSeat(t *testing.T) (*Connection, *wlturbo.Display, *net.UnixConn) {
269- t.Helper()
270- ca, server := inputPair(t)
271- d, err := wlturbo.ConnectFromConn(ca)
272- if err != nil {
273- t.Fatal(err)
274- }
275- t.Cleanup(func() { _ = d.Close() })
276- inputRequest(t, server)
277- c := &Connection{Display: d, events: make(chan Event, 256), stop: make(chan struct{}), windows: make(map[uint32]*Window)}
278- c.seat = &Window{Display: d, connection: c, seatVersion: 9}
279- c.seat.Seat = core.NewSeat(d.Context())
280- if err := d.Registry().Bind(1, core.SeatInterface, 9, c.seat.Seat); err != nil {
281- t.Fatal(err)
282- }
283- inputRequest(t, server)
284- return c, d, server
285-}
286-
287-func fdOpen(fd int) bool {
288- _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
289- return err == nil
290-}
291-
292-func TestKeyboardGenerationKeymapClosedExactlyOnce(t *testing.T) {
293- c, d, server := sharedSeat(t)
294- for generation := range 3 {
295- if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 2}); err != nil {
296- t.Fatal(err)
297- }
298- inputRequest(t, server) // get_keyboard
299- cache := c.seat.keymapCache
300- if cache == nil || c.seat.WLKeyboard == nil {
301- t.Fatalf("generation %d: no keyboard cache", generation)
302- }
303- f, err := os.CreateTemp(t.TempDir(), "keymap")
304- if err != nil {
305- t.Fatal(err)
306- }
307- var taken []int
308- for range 2 { // a second keymap in one generation replaces the first
309- inputSend(t, server, inputFrame(c.seat.WLKeyboard.ID(), 0, 1, 4), int(f.Fd()))
310- if err = d.Dispatch(); err != nil {
311- t.Fatal(err)
312- }
313- cache.mu.Lock()
314- taken = append(taken, cache.fd)
315- cache.mu.Unlock()
316- }
317- f.Close()
318- if taken[0] == taken[1] || fdOpen(taken[0]) {
319- t.Fatalf("replaced keymap FD %d not closed", taken[0])
320- }
321- if !fdOpen(taken[1]) {
322- t.Fatalf("generation %d: live keymap FD closed", generation)
323- }
324- if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil {
325- t.Fatal(err)
326- }
327- inputRequest(t, server) // release
328- if fdOpen(taken[1]) {
329- t.Fatalf("generation %d: keymap FD %d leaked after capability loss", generation, taken[1])
330- }
331- if c.seat.keymapCache != nil || c.seat.WLKeyboard != nil {
332- t.Fatal("keyboard generation not retired")
333- }
334- cache.close() // a retired cache closes nothing twice
335- }
336-}
337-
338-func TestPointerReplacementResetsSiblingCursor(t *testing.T) {
339- c, d, server := sharedSeat(t)
340- ctx := d.Context()
341- shapes := cursorshape.NewWpCursorShapeManager(ctx)
342- if err := d.Registry().Bind(2, cursorshape.WpCursorShapeManagerInterface, 1, shapes); err != nil {
343- t.Fatal(err)
344- }
345- inputRequest(t, server)
346- sibling := &Window{connection: c, CursorShapes: shapes, seatVersion: 9}
347- c.windows[1] = sibling
348- if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil {
349- t.Fatal(err)
350- }
351- if id, op, _ := readRequest(t, server); id != c.seat.Seat.ID() || op != 0 {
352- t.Fatalf("get_pointer %d %d", id, op)
353- }
354- first := sibling.Pointer
355- if first == nil || sibling.pointerGen != 1 {
356- t.Fatalf("sibling pointer/gen %v %d", first, sibling.pointerGen)
357- }
358- sibling.PointerEntered(7, 1)
359- if err := sibling.SetCursor(CursorPointer); err != nil {
360- t.Fatal(err)
361- }
362- _, _, body := readRequest(t, server) // shape device
363- device := binary.LittleEndian.Uint32(body)
364- readRequest(t, server) // set_shape
365- // Pointer capability loss: device destroyed before the pointer, serial gone.
366- if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil {
367- t.Fatal(err)
368- }
369- if id, op, _ := readRequest(t, server); id != device || op != 0 {
370- t.Fatalf("shape device not destroyed first: %d %d", id, op)
371- }
372- if id, _, _ := readRequest(t, server); id != first.ID() {
373- t.Fatalf("pointer not released after device: %d", id)
374- }
375- if sibling.Pointer != nil || sibling.cursor.serial != 0 || sibling.cursor.device != nil {
376- t.Fatal("sibling kept stale pointer state")
377- }
378- // Replacement: a late enter of the released generation is ignored.
379- if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil {
380- t.Fatal(err)
381- }
382- readRequest(t, server) // get_pointer
383- if sibling.pointerGen != 2 || sibling.Pointer == nil || sibling.Pointer == first {
384- t.Fatalf("replacement not adopted: gen %d", sibling.pointerGen)
385- }
386- sibling.PointerEntered(9, 1)
387- if err := sibling.SetCursor(CursorPointer); err != nil {
388- t.Fatal(err)
389- }
390- noRequest(t, server)
391- sibling.PointerEntered(11, 2)
392- if err := sibling.SetCursor(CursorPointer); err != nil {
393- t.Fatal(err)
394- }
395- _, _, body = readRequest(t, server)
396- if binary.LittleEndian.Uint32(body[4:]) != sibling.Pointer.ID() {
397- t.Fatal("shape device bound to a stale pointer")
398- }
399- if _, _, body = readRequest(t, server); binary.LittleEndian.Uint32(body) != 11 {
400- t.Fatalf("set_shape used stale serial: %x", body)
401- }
402-}
403-
404-func TestOwnerSeatRemovalWithFullReaderQueue(t *testing.T) {
405- c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{}}
406- c.seat = &Window{connection: c}
407- lock := &Window{LockSurface: nil, rawInput: []Event{{Kind: InputKey}}}
408- c.windows[1], c.windows[2] = &Window{}, lock
409- for range cap(c.events) {
410- c.events <- Event{Kind: LockAcquired}
411- }
412- done := make(chan error, 1)
413- go func() { done <- c.Apply(Event{Kind: SeatRemoved}) }()
414- select {
415- case err := <-done:
416- if err != nil {
417- t.Fatal(err)
418- }
419- case <-time.After(time.Second):
420- close(c.stop)
421- t.Fatal("seat removal blocked on the full reader queue")
422- }
423- if len(c.events) != cap(c.events) {
424- t.Fatal("owner apply consumed or added reader events")
425- }
426-}
427-
428-func TestReaderPauseNeverBlocksOnFullQueueAndResumes(t *testing.T) {
429- ca, server := inputPair(t)
430- d, err := wlturbo.ConnectFromConn(ca)
431- if err != nil {
432- t.Fatal(err)
433- }
434- inputRequest(t, server)
435- c := &Connection{Display: d, socket: ca, events: make(chan Event, 1), stop: make(chan struct{}), windows: map[uint32]*Window{}, pauseSig: make(chan struct{}), pauseAck: make(chan struct{}, 1), resume: make(chan struct{}, 1)}
436- seat := core.NewSeat(d.Context())
437- if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil {
438- t.Fatal(err)
439- }
440- inputRequest(t, server)
441- seat.OnCapabilities(func(caps uint32) { c.post(Event{Kind: SeatCapabilities, Capabilities: caps}) })
442- c.StartReader()
443- defer func() { _ = c.Close() }()
444- // Fill the one-slot queue, then a second event blocks the reader in post.
445- inputSend(t, server, inputFrame(seat.ID(), 0, 1))
446- inputSend(t, server, inputFrame(seat.ID(), 0, 2))
447- time.Sleep(50 * time.Millisecond)
448- paused := make(chan func(), 1)
449- go func() { paused <- c.pauseReader() }()
450- var resume func()
451- select {
452- case resume = <-paused:
453- case <-time.After(2 * time.Second):
454- t.Fatal("pause deadlocked against the full reader queue")
455- }
456- if first := <-c.events; first.Capabilities != 1 {
457- t.Fatalf("first event %+v", first)
458- }
459- resume()
460- select {
461- case ev := <-c.events:
462- if ev.Capabilities != 2 {
463- t.Fatalf("parked event lost or reordered: %+v", ev)
464- }
465- case <-time.After(2 * time.Second):
466- t.Fatal("parked event not re-posted after resume")
467- }
468- // A pause with an idle blocked read is interrupted too, and reading resumes.
469- resume2 := c.pauseReader()
470- resume2()
471- inputSend(t, server, inputFrame(seat.ID(), 0, 4))
472- select {
473- case ev := <-c.events:
474- if ev.Capabilities != 4 {
475- t.Fatalf("event after resume %+v", ev)
476- }
477- case <-time.After(2 * time.Second):
478- t.Fatal("reader did not resume")
479- }
480-}
481-
482-func TestReadyRequiresConfigureFeedbackAndFrame(t *testing.T) {
483- w := &Window{Configured: true, FrameReady: true}
484- if w.Ready() {
485- t.Fatal("ready before dmabuf feedback completed")
486- }
487- w.FeedbackDone = true
488- if !w.Ready() {
489- t.Fatal("not ready")
490- }
491- // A later feedback round invalidates readiness until its own done.
492- if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 2}); err != nil {
493- t.Fatal(err)
494- }
495- if w.Ready() || len(w.Tranches) != 0 {
496- t.Fatal("stale feedback kept across rounds")
497- }
498- if err := w.Present(nil, nil, nil, 0, 0); err == nil {
499- t.Fatal("present without buffer accepted")
500- }
501-}
502-
503-func TestDrainStartupHoldsLockAndOutputEventsInOrder(t *testing.T) {
504- c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}, outputs: map[uint32]Output{}}
505- c.events <- Event{Kind: OutputAdded, Output: Output{Global: 3}}
506- c.events <- Event{Kind: LockAcquired}
507- c.events <- Event{Kind: OutputRemoved, Output: Output{Global: 3}}
508- if err := c.drainStartup(true); err != nil {
509- t.Fatal(err)
510- }
511- if c.Locked() || c.lockDone {
512- t.Fatal("startup drain applied `locked`, hiding it from the owner")
513- }
514- held := c.TakeHeld()
515- if len(held) != 3 || held[0].Kind != OutputAdded || held[1].Kind != LockAcquired || held[2].Kind != OutputRemoved {
516- t.Fatalf("held %+v", held)
517- }
518- if len(c.TakeHeld()) != 0 {
519- t.Fatal("held events delivered twice")
520- }
521- // The owner applies them itself; unlock then becomes possible.
522- for _, ev := range held {
523- if err := c.Apply(ev); err != nil {
524- t.Fatal(err)
525- }
526- }
527- if !c.Locked() {
528- t.Fatal("locked not recorded once the owner applied it")
529- }
530-}
531-
532-func TestDrainStartupFinishedWrapsSentinelAndKeepsEvent(t *testing.T) {
533- c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}}
534- c.events <- Event{Kind: LockFinished}
535- err := c.drainStartup(true)
536- if !errors.Is(err, ErrLockFinished) {
537- t.Fatalf("err %v", err)
538- }
539- if held := c.TakeHeld(); len(held) != 1 || held[0].Kind != LockFinished {
540- t.Fatalf("finished event lost: %+v", held)
541- }
542- // Window-tagged and ordinary events are still applied during setup.
543- c.events <- Event{Kind: BufferRelease, BufferID: 1}
544- if err := c.drainStartup(true); err == nil || errors.Is(err, ErrLockFinished) {
545- t.Logf("non-lock event handled: %v", err)
546- }
547-}
548-
549-// A closing session must never read the shared queue: it carries other
550-// windows' releases, lock state, output changes and transport errors.
551-func TestSharedWindowCloseLeavesReaderQueueAlone(t *testing.T) {
552- c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}}
553- closing, other := &Window{connection: c, events: c.events}, &Window{connection: c, events: c.events}
554- if closing.ReaderEvents() != nil {
555- t.Fatal("shared window exposes the connection queue to its session")
556- }
557- if (&Window{events: make(chan Event)}).ReaderEvents() == nil {
558- t.Fatal("standalone window lost its own queue")
559- }
560- queued := []Event{
561- {Kind: BufferRelease, BufferID: 1, Window: other},
562- {Kind: LockAcquired},
563- {Kind: OutputRemoved, Output: Output{Global: 9}},
564- {Kind: TransportError, Err: errors.New("gone")},
565- {Kind: InputKey, Code: 30, Window: other},
566- }
567- for _, ev := range queued {
568- c.events <- ev
569- }
570- if err := closing.Close(); err != nil {
571- t.Fatal(err)
572- }
573- if len(c.events) != len(queued) {
574- t.Fatalf("close consumed %d shared events", len(queued)-len(c.events))
575- }
576- for i, want := range queued {
577- if got := <-c.events; got.Kind != want.Kind || got.Window != want.Window {
578- t.Fatalf("event %d reordered: %+v", i, got)
579- }
580- }
581-}
D internal/platform/wayland/cursor.go +0 -63
@@ -1,63 +0,0 @@
1-package wayland
2-
3-import "github.com/bnema/wlturbo/protocol/cursorshape"
4-
5-// Cursor shapes NeferGUI maps from CSS `cursor`, in wp_cursor_shape_device_v1
6-// enum values.
7-const (
8- CursorDefault = cursorshape.SHAPE_DEFAULT
9- CursorPointer = cursorshape.SHAPE_POINTER
10- CursorText = cursorshape.SHAPE_TEXT
11- CursorNotAllowed = cursorshape.SHAPE_NOT_ALLOWED
12-)
13-
14-// cursorState is owned by the session loop. The shape device belongs to the
15-// current wl_pointer; set_shape needs the serial of its latest enter.
16-type cursorState struct {
17- device *cursorshape.WpCursorShapeDevice
18- serial uint32 // latest enter serial, 0 while outside the surface
19- shape uint32 // last shape sent at serial
20-}
21-
22-// PointerEntered records the enter serial and resets the sent shape: the
23-// compositor forgets the client cursor when the pointer leaves the surface.
24-// An enter from a released pointer (gen not current) is ignored.
25-func (w *Window) PointerEntered(serial uint32, gen uint64) {
26- if w.Pointer == nil || gen != w.pointerGen {
27- return
28- }
29- w.cursor.serial, w.cursor.shape = serial, 0
30-}
31-
32-// PointerLeft marks the pointer outside; shapes are not sent until enter.
33-func (w *Window) PointerLeft() { w.cursor.serial, w.cursor.shape = 0, 0 }
34-
35-// SetCursor requests shape for the pointer over the surface. It is a no-op
36-// without cursor-shape support, outside the surface, or when unchanged.
37-func (w *Window) SetCursor(shape uint32) error {
38- if w.CursorShapes == nil || w.Pointer == nil || w.cursor.serial == 0 || shape == w.cursor.shape {
39- return nil
40- }
41- if w.cursor.device == nil {
42- device, err := w.CursorShapes.GetPointer(w.Pointer)
43- if err != nil {
44- return err
45- }
46- w.cursor.device = device
47- }
48- if err := w.cursor.device.SetShape(w.cursor.serial, shape); err != nil {
49- return err
50- }
51- w.cursor.shape = shape
52- return nil
53-}
54-
55-// dropCursorDevice destroys the shape device; call it before its wl_pointer
56-// is released. The enter serial belongs to that pointer, so it is cleared:
57-// a replacement pointer sets shapes only after its own enter.
58-func (w *Window) dropCursorDevice() {
59- if w.cursor.device != nil {
60- _ = w.cursor.device.Destroy()
61- }
62- w.cursor = cursorState{}
63-}
D internal/platform/wayland/cursor_test.go +0 -139
@@ -1,139 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "encoding/binary"
7- "io"
8- "net"
9- "testing"
10- "time"
11-
12- "github.com/bnema/wlturbo"
13- "github.com/bnema/wlturbo/protocol/core"
14- "github.com/bnema/wlturbo/protocol/cursorshape"
15-)
16-
17-// readRequest returns the object ID, opcode and body of the next request.
18-// Requests can share one socket read, so each is framed by its size field.
19-func readRequest(t *testing.T, p *net.UnixConn) (uint32, uint16, []byte) {
20- t.Helper()
21- _ = p.SetReadDeadline(time.Now().Add(time.Second))
22- header := make([]byte, 8)
23- if _, err := io.ReadFull(p, header); err != nil {
24- t.Fatalf("read request header: %v", err)
25- }
26- size := int(binary.LittleEndian.Uint32(header[4:]) >> 16)
27- body := make([]byte, size-8)
28- if _, err := io.ReadFull(p, body); err != nil {
29- t.Fatalf("read request body: %v", err)
30- }
31- return binary.LittleEndian.Uint32(header), uint16(binary.LittleEndian.Uint32(header[4:])), body
32-}
33-
34-// noRequest asserts the client sent nothing.
35-func noRequest(t *testing.T, p *net.UnixConn) {
36- t.Helper()
37- _ = p.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
38- b := make([]byte, 64)
39- if n, err := p.Read(b); err == nil {
40- t.Fatalf("unexpected request %x", b[:n])
41- }
42-}
43-
44-func TestCursorShapeWire(t *testing.T) {
45- ca, server := inputPair(t)
46- d, err := wlturbo.ConnectFromConn(ca)
47- if err != nil {
48- t.Fatal(err)
49- }
50- defer d.Close()
51- inputRequest(t, server)
52- ctx := d.Context()
53- pointer := core.NewPointer(ctx)
54- pointer.SetID(d.AllocateID())
55- ctx.Register(pointer)
56- shapes := cursorshape.NewWpCursorShapeManager(ctx)
57- if err = d.Registry().Bind(1, cursorshape.WpCursorShapeManagerInterface, 1, shapes); err != nil {
58- t.Fatal(err)
59- }
60- inputRequest(t, server)
61- w := &Window{Pointer: pointer, CursorShapes: shapes, pointerGen: 1}
62-
63- // Outside the surface nothing is sent.
64- if err = w.SetCursor(CursorPointer); err != nil {
65- t.Fatal(err)
66- }
67- noRequest(t, server)
68-
69- // First shape after enter creates the device, then sets the shape at the
70- // enter serial.
71- w.PointerEntered(7, 1)
72- if err = w.SetCursor(CursorPointer); err != nil {
73- t.Fatal(err)
74- }
75- id, op, body := readRequest(t, server)
76- if id != shapes.ID() || op != 1 || binary.LittleEndian.Uint32(body[4:]) != pointer.ID() {
77- t.Fatalf("get_pointer id=%d op=%d %x", id, op, body)
78- }
79- device := binary.LittleEndian.Uint32(body)
80- id, op, body = readRequest(t, server)
81- if id != device || op != 1 || binary.LittleEndian.Uint32(body) != 7 || binary.LittleEndian.Uint32(body[4:]) != CursorPointer {
82- t.Fatalf("set_shape id=%d op=%d %x", id, op, body)
83- }
84-
85- // An unchanged shape is not re-sent; a new one reuses the device.
86- if err = w.SetCursor(CursorPointer); err != nil {
87- t.Fatal(err)
88- }
89- noRequest(t, server)
90- if err = w.SetCursor(CursorText); err != nil {
91- t.Fatal(err)
92- }
93- if id, _, body = readRequest(t, server); id != device || binary.LittleEndian.Uint32(body[4:]) != CursorText {
94- t.Fatalf("text shape id=%d %x", id, body)
95- }
96-
97- // After leave nothing is sent; re-enter re-sends even the same shape,
98- // at the new serial.
99- w.PointerLeft()
100- if err = w.SetCursor(CursorDefault); err != nil {
101- t.Fatal(err)
102- }
103- noRequest(t, server)
104- w.PointerEntered(9, 1)
105- if err = w.SetCursor(CursorText); err != nil {
106- t.Fatal(err)
107- }
108- if id, _, body = readRequest(t, server); id != device || binary.LittleEndian.Uint32(body) != 9 || binary.LittleEndian.Uint32(body[4:]) != CursorText {
109- t.Fatalf("re-enter shape id=%d %x", id, body)
110- }
111-
112- // Dropping the device (pointer capability loss) destroys it.
113- w.dropCursorDevice()
114- if id, op, _ = readRequest(t, server); id != device || op != 0 {
115- t.Fatalf("destroy id=%d op=%d", id, op)
116- }
117- // The old enter serial went with the pointer: nothing is sent until the
118- // replacement pointer's own enter, and a late enter from the released
119- // pointer (older generation) is ignored.
120- if err = w.SetCursor(CursorPointer); err != nil {
121- t.Fatal(err)
122- }
123- noRequest(t, server)
124- w.pointerGen = 2
125- w.PointerEntered(11, 1)
126- if err = w.SetCursor(CursorPointer); err != nil {
127- t.Fatal(err)
128- }
129- noRequest(t, server)
130-}
131-
132-// Without the global, SetCursor is a silent no-op.
133-func TestCursorShapeUnsupported(t *testing.T) {
134- w := &Window{Pointer: &core.Pointer{}}
135- w.PointerEntered(1, 0)
136- if err := w.SetCursor(CursorPointer); err != nil {
137- t.Fatal(err)
138- }
139-}
D internal/platform/wayland/events.go +0 -271
@@ -1,271 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "fmt"
7- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
8- "net"
9- "time"
10-)
11-
12-type EventKind uint8
13-
14-const (
15- ConfigureSize EventKind = iota + 1
16- ConfigureSerial
17- PreferredScale
18- BufferScale
19- CloseEvent
20- FrameEvent
21- BufferRelease
22- TransportError
23- SeatCapabilities
24- SeatRemoved
25- InputFrame
26- InputKeymap
27- InputFocusIn
28- InputFocusOut
29- InputKey
30- InputModifiers
31- InputRepeatInfo
32- FeedbackError
33- FeedbackMainDevice
34- FeedbackTranche
35- FeedbackFormats
36- FeedbackComplete
37- ClipboardInput
38- IMEInput
39- // ConfigureLayer carries a layer-surface extent and its configure serial;
40- // Apply resizes and acks together. Zero axes are unchanged.
41- ConfigureLayer
42- ConfigureLock
43- OutputGlobal
44- OutputNamed
45- OutputScale
46- OutputAdded
47- OutputRemoved
48- LockAcquired
49- LockFinished
50-)
51-
52-type Event struct {
53- Window *Window // tagged by transport, applied only by the shared UI owner
54- Output Output
55- Version uint32
56- Kind EventKind
57- Width, Height int32
58- Serial, Scale uint32
59- Factor int32
60- BufferID uint64
61- Err error
62- Device uint64
63- Tranche, Formats []linuxdmabuf.FormatEntry
64- Inputs []Input
65- Clipboard ClipboardEvent
66- IME IMEEvent
67- Capabilities, Format, Size, Code uint32
68- FD int // owned by ApplyInput or CloseEventFD when Kind == InputKeymap
69- Pressed bool
70- Mods [4]uint32
71- Rate, Delay int32
72-}
73-
74-// StartReader launches exactly one blocking Wayland reader. Registered callbacks
75-// only send typed values to Events; Apply is called by the session loop alone.
76-func (w *Window) StartReader() <-chan Event {
77- if w.connection != nil {
78- return w.connection.StartReader()
79- }
80- w.readerOnce.Do(func() {
81- w.readerStarted.Store(true)
82- w.readerDone = make(chan struct{})
83- go func() {
84- defer close(w.readerDone)
85- for {
86- err := w.Display.Dispatch()
87- if err != nil {
88- select {
89- case <-w.readerStop:
90- return
91- case w.events <- Event{Kind: TransportError, Err: err}:
92- return
93- }
94- }
95- }
96- }()
97- })
98- return w.events
99-}
100-
101-// Events exposes the reader's typed event queue to the session owner.
102-func (w *Window) Events() <-chan Event { return w.events }
103-
104-// ReaderEvents is the queue a closing session may drain. It is nil for a window
105-// on a Connection it does not own: that queue carries other windows' events,
106-// the lock state and transport errors, and belongs to the connection owner.
107-func (w *Window) ReaderEvents() <-chan Event {
108- if w.connection != nil && !w.ownsConnection {
109- return nil
110- }
111- return w.events
112-}
113-
114-// PostClipboard and PostIME enqueue transport callbacks for the owner loop.
115-func (w *Window) PostClipboard(ev ClipboardEvent) { w.post(Event{Kind: ClipboardInput, Clipboard: ev}) }
116-func (w *Window) PostIME(ev IMEEvent) { w.post(Event{Kind: IMEInput, IME: ev}) }
117-
118-func (w *Window) post(ev Event) {
119- if w.connection != nil {
120- if w != w.connection.seat {
121- ev.Window = w
122- }
123- w.connection.post(ev)
124- return
125- }
126- select {
127- case <-w.readerStop:
128- return
129- case w.events <- ev:
130- }
131-}
132-
133-// postFD closes an undeliverable keymap descriptor rather than leaking it.
134-func (w *Window) postFD(ev Event) {
135- if w.connection != nil {
136- w.post(ev)
137- return
138- }
139- select {
140- case <-w.readerStop:
141- CloseEventFD(ev)
142- case w.events <- ev:
143- }
144-}
145-
146-// BufferReleased is safe to call from the reader callback: it never touches
147-// Pool, Session, or Vulkan. The serial loop records the optional event.
148-func (w *Window) BufferReleased(id uint64) {
149- w.post(Event{Kind: BufferRelease, BufferID: id})
150-}
151-func (w *Window) Apply(ev Event) error {
152- if w.connection != nil && (ev.Window == nil || ev.Window != w) {
153- return w.connection.Apply(ev)
154- }
155- if w.Closed {
156- CloseEventFD(ev)
157- return nil
158- }
159- switch ev.Kind {
160- case ConfigureSize:
161- if ev.Width > 0 && ev.Height > 0 && (w.Width != ev.Width || w.Height != ev.Height) {
162- w.Width, w.Height, w.Dirty = ev.Width, ev.Height, true
163- }
164- case ConfigureSerial:
165- var err error
166- if w.XdgSurface != nil {
167- err = w.XdgSurface.AckConfigure(ev.Serial)
168- } else {
169- err = fmt.Errorf("configure without a shell surface")
170- }
171- if err != nil {
172- return err
173- }
174- w.Configured, w.FrameReady = true, true
175- case ConfigureLock:
176- if w.LockSurface == nil || ev.Width <= 0 || ev.Height <= 0 || ev.Width > 16384 || ev.Height > 16384 {
177- return fmt.Errorf("invalid lock configure %dx%d", ev.Width, ev.Height)
178- }
179- if err := w.LockSurface.AckConfigure(ev.Serial); err != nil {
180- return err
181- }
182- w.Width, w.Height = ev.Width, ev.Height
183- w.Configured, w.FrameReady, w.Dirty = true, true, true
184- case ConfigureLayer:
185- w.applyLayerSize(ev.Width, ev.Height)
186- if w.LayerSurface == nil {
187- return fmt.Errorf("configure without a layer surface")
188- }
189- if err := w.LayerSurface.AckConfigure(ev.Serial); err != nil {
190- return err
191- }
192- w.Configured, w.FrameReady = true, true
193- case PreferredScale:
194- if ev.Scale == 0 {
195- return fmt.Errorf("invalid preferred scale zero")
196- }
197- if ev.Scale%120 != 0 && w.Viewport == nil {
198- return &CapabilityError{Name: "wp_viewporter", Cause: fmt.Errorf("fractional scale %d/120 requires viewporter", ev.Scale)}
199- }
200- if scale := float64(ev.Scale) / 120; w.Scale != scale {
201- w.Scale = scale
202- w.Dirty = true
203- }
204- case BufferScale:
205- if w.Fractional == nil && ev.Factor > 0 && w.Scale != float64(ev.Factor) {
206- w.Scale = float64(ev.Factor)
207- w.Dirty = true
208- }
209- case CloseEvent:
210- w.Closed = true
211- case FrameEvent:
212- w.FrameReady = true
213- case FeedbackError:
214- if w.feedbackErr == nil {
215- w.feedbackErr = ev.Err
216- }
217- return ev.Err
218- case FeedbackMainDevice:
219- if w.FeedbackDone { // a new feedback round replaces the previous one
220- w.FeedbackDone, w.Tranches, w.Formats = false, nil, nil
221- }
222- w.MainDevice = ev.Device
223- case FeedbackTranche:
224- w.Tranches = append(w.Tranches, ev.Tranche)
225- case FeedbackFormats:
226- w.Formats = ev.Formats
227- case FeedbackComplete:
228- if w.feedbackErr != nil {
229- return w.feedbackErr
230- }
231- w.FeedbackDone = true
232- case SeatCapabilities, SeatRemoved:
233- return w.ApplySeat(ev)
234- case ClipboardInput, IMEInput:
235- // These are applied by the session owner after Window.Apply.
236- case InputFrame, InputKeymap, InputFocusIn, InputFocusOut, InputKey, InputModifiers, InputRepeatInfo:
237- if w.LockSurface != nil {
238- if ev.Kind == InputFrame {
239- return nil // pointer input has no consumer on a lock surface
240- }
241- // Lock owners consume numeric/raw protocol events through their
242- // secret keyboard path. Never instantiate the ordinary translator.
243- if len(w.rawInput) >= 4096 {
244- CloseEventFD(ev)
245- return fmt.Errorf("lock raw input queue full")
246- }
247- w.rawInput = append(w.rawInput, ev)
248- return nil
249- }
250- if w.inputAdapter == nil {
251- var err error
252- w.inputAdapter, err = NewInputAdapter("")
253- if err != nil {
254- CloseEventFD(ev)
255- return err
256- }
257- }
258- inputs, err := w.inputAdapter.ApplyInput(ev, time.Now())
259- w.queueInput(inputs)
260- return err
261- case BufferRelease: // consumed by the session owner; no pixel ownership implication
262- case TransportError:
263- if ev.Err != nil {
264- return ev.Err
265- }
266- return net.ErrClosed
267- default:
268- return fmt.Errorf("unknown Wayland event %d", ev.Kind)
269- }
270- return nil
271-}
D internal/platform/wayland/harness_input_test.go +0 -168
@@ -1,168 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "context"
7- "fmt"
8- "os"
9- "path/filepath"
10- "strings"
11- "testing"
12- "time"
13-
14- "github.com/bnema/nefergui/internal/harness"
15- "github.com/bnema/wlturbo/protocol/core"
16- "github.com/bnema/wlturbo/protocol/xdgshell"
17- "github.com/bnema/wlturbo/wl"
18- "golang.org/x/sys/unix"
19-)
20-
21-// The child uses a plain shm toplevel so the test does not depend on a GPU
22-// renderer. The parent runs the pinned compositor with its real input script.
23-func TestHarnessInput(t *testing.T) {
24- if os.Getenv("NEFERGUI_HARNESS") != "1" {
25- t.Skip("set NEFERGUI_HARNESS=1 for NeferWL input injection")
26- }
27- script := filepath.Join(t.TempDir(), "input.txt")
28- if err := os.WriteFile(script, []byte("sleep 2s\nmove 100 100\nsleep 200ms\nclick left\nkey a\nsleep 500ms\n"), 0600); err != nil {
29- t.Fatal(err)
30- }
31- root := filepath.Join(t.TempDir(), "artifacts")
32- result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Input: script, Timeout: 25 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessInputClient$", "-test.v"}})
33- if code != harness.Pass {
34- t.Fatalf("harness code=%d status=%s error=%s artifacts=%s", code, result.Status, result.Error, root)
35- }
36- data, err := os.ReadFile(result.Artifacts["client_stdout"])
37- if err != nil {
38- t.Fatal(err)
39- }
40- for _, want := range []string{"INPUT motion", "INPUT press", "INPUT release", "INPUT key a"} {
41- if !strings.Contains(string(data), want) {
42- t.Fatalf("missing %s in client output:\n%s", want, data)
43- }
44- }
45-}
46-
47-func TestHarnessInputClient(t *testing.T) {
48- if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
49- t.Skip("harness child only")
50- }
51- d, err := wl.Connect("")
52- if err != nil {
53- t.Fatal(err)
54- }
55- defer d.Close()
56- if err = d.Roundtrip(); err != nil {
57- t.Fatal(err)
58- }
59- ctx := d.Context()
60- compositor := core.NewCompositor(ctx)
61- if _, err = d.Registry().BindNegotiated(core.CompositorInterface, 6, compositor); err != nil {
62- t.Fatal(err)
63- }
64- shell := xdgshell.NewXdgWmBase(ctx)
65- if _, err = d.Registry().BindNegotiated(xdgshell.XdgWmBaseInterface, 6, shell); err != nil {
66- t.Fatal(err)
67- }
68- shm := core.NewShm(ctx)
69- if _, err = d.Registry().BindNegotiated(core.ShmInterface, 1, shm); err != nil {
70- t.Fatal(err)
71- }
72- surface, err := compositor.CreateSurface()
73- if err != nil {
74- t.Fatal(err)
75- }
76- xdg, err := shell.GetXdgSurface(surface)
77- if err != nil {
78- t.Fatal(err)
79- }
80- if _, err = xdg.GetToplevel(); err != nil {
81- t.Fatal(err)
82- }
83- configured := false
84- xdg.OnConfigure(func(serial uint32) {
85- if e := xdg.AckConfigure(serial); e != nil {
86- t.Error(e)
87- }
88- configured = true
89- })
90- if err = surface.Commit(); err != nil {
91- t.Fatal(err)
92- }
93- for !configured {
94- if err = d.Dispatch(); err != nil {
95- t.Fatal(err)
96- }
97- }
98- fd, err := unix.MemfdCreate("nefergui-harness-input", unix.MFD_CLOEXEC)
99- if err != nil {
100- t.Fatal(err)
101- }
102- if err = unix.Ftruncate(fd, 400*300*4); err != nil {
103- t.Fatal(err)
104- }
105- pool, err := shm.CreatePool(fd, 400*300*4) // consumes fd on success
106- if err != nil {
107- _ = unix.Close(fd)
108- t.Fatal(err)
109- }
110- buffer, err := pool.CreateBuffer(0, 400, 300, 400*4, 0) // wl_shm XRGB8888
111- if err != nil {
112- t.Fatal(err)
113- }
114- if err = surface.Attach(buffer, 0, 0); err != nil {
115- t.Fatal(err)
116- }
117- if err = surface.DamageBuffer(0, 0, 400, 300); err != nil {
118- t.Fatal(err)
119- }
120- if err = surface.Commit(); err != nil {
121- t.Fatal(err)
122- }
123- w := &Window{Display: d, Surface: surface, events: make(chan Event, 128), readerStop: make(chan struct{})}
124- if err = w.BindSeat(); err != nil {
125- t.Fatal(err)
126- }
127- adapter, err := NewInputAdapter("en_US.UTF-8")
128- if err != nil {
129- t.Fatal(err)
130- }
131- defer adapter.Close()
132- deadline := time.After(6 * time.Second)
133- seen := map[string]bool{}
134- for !(seen["motion"] && seen["press"] && seen["release"] && seen["key a"]) {
135- select {
136- case <-deadline:
137- t.Fatalf("timed out waiting for injected input: %v", seen)
138- default:
139- }
140- if err = d.Dispatch(); err != nil {
141- t.Fatal(err)
142- }
143- for len(w.events) > 0 {
144- ev := <-w.events
145- switch ev.Kind {
146- case SeatCapabilities, SeatRemoved:
147- if err = w.ApplySeat(ev); err != nil {
148- t.Fatal(err)
149- }
150- case InputFrame, InputKeymap, InputFocusIn, InputFocusOut, InputKey, InputModifiers, InputRepeatInfo:
151- inputs, e := adapter.ApplyInput(ev, time.Now())
152- if e != nil {
153- t.Fatal(e)
154- }
155- for _, in := range inputs {
156- kind := in.Kind
157- if kind == "key" && in.Key.Pressed && in.Key.Text == "a" {
158- kind = "key a"
159- }
160- if kind == "motion" || kind == "press" || kind == "release" || kind == "key a" {
161- seen[kind] = true
162- fmt.Println("INPUT", kind)
163- }
164- }
165- }
166- }
167- }
168-}
D internal/platform/wayland/ime.go +0 -329
@@ -1,329 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "errors"
7- "math"
8- "unicode/utf8"
9-
10- "github.com/bnema/nefergui/internal/edit"
11- "github.com/bnema/wlturbo"
12- "github.com/bnema/wlturbo/protocol/core"
13- "github.com/bnema/wlturbo/protocol/textinput"
14- "github.com/bnema/wlturbo/wl"
15-)
16-
17-// IMEEvent is posted from the Wayland reader to the session loop. Only a done
18-// event produces an edit.IMEBatch; intermediate protocol events are buffered.
19-type IMEEvent struct {
20- Kind string
21- Surface uint32
22- Text string
23- Begin, End int32
24- Before, After uint32
25- Serial uint32
26-}
27-
28-// IME is loop-owned. On errors in the edit.IME interface, Err records the
29-// first protocol send failure; the session owner should inspect it each frame.
30-type IME struct {
31- manager *textinput.TextInputManagerV3
32- input *textinput.TextInputV3
33- surface uint32
34- post func(IMEEvent)
35- batch edit.IMEBatch
36- commits uint32
37- entered, enabled bool
38- requested, synced bool
39- pendingSurround string
40- hasSurround bool
41- pendingCursor, pendingAnchor int
42- pendingContent bool
43- password, multiline bool
44- pendingRect bool
45- rect [4]int32
46- Err error
47-}
48-
49-var _ edit.IME = (*IME)(nil)
50-
51-// NewIME returns nil if text-input-v3 is not announced; keyboard remains usable.
52-func NewIME(d *wl.Display, seat *core.Seat, surface *core.Surface, post func(IMEEvent)) (*IME, error) {
53- if d == nil || seat == nil || surface == nil {
54- return nil, nil
55- }
56- manager := textinput.NewTextInputManagerV3(d.Context())
57- if _, err := d.Registry().BindNegotiated(textinput.TextInputManagerV3Interface, 2, manager); err != nil {
58- d.Context().Unregister(manager)
59- if errors.Is(err, wlturbo.ErrGlobalNotFound) {
60- return nil, nil
61- }
62- return nil, err
63- }
64- input, err := manager.GetTextInput(seat)
65- if err != nil {
66- _ = manager.Destroy()
67- return nil, err
68- }
69- m := &IME{manager: manager, input: input, surface: surface.ID(), post: post}
70- input.OnEnter(func(id uint32) { post(IMEEvent{Kind: "enter", Surface: id}) })
71- input.OnLeave(func(id uint32) { post(IMEEvent{Kind: "leave", Surface: id}) })
72- input.OnPreeditString(func(s string, b, e int32) { post(IMEEvent{Kind: "preedit", Text: s, Begin: b, End: e}) })
73- input.OnCommitString(func(s string) { post(IMEEvent{Kind: "commit", Text: s}) })
74- input.OnDeleteSurroundingText(func(b, a uint32) { post(IMEEvent{Kind: "delete", Before: b, After: a}) })
75- input.OnDone(func(serial uint32) { post(IMEEvent{Kind: "done", Serial: serial}) })
76- return m, nil
77-}
78-func (m *IME) send(err error) {
79- if err != nil && m.Err == nil {
80- m.Err = err
81- }
82-}
83-func (m *IME) commit() {
84- if m == nil || m.input == nil {
85- return
86- }
87- if err := m.input.Commit(); err != nil {
88- m.send(err)
89- } else {
90- m.commits++
91- }
92-}
93-func (m *IME) Enable() {
94- if m == nil {
95- return
96- }
97- m.requested = true
98- if !m.entered || m.enabled {
99- return
100- }
101- m.send(m.input.Enable())
102- if m.Err == nil {
103- m.enabled = true
104- m.synced = true
105- // Initial state is double-buffered with enable in one commit.
106- if m.pendingContent {
107- m.pendingContent = false
108- m.send(m.input.SetContentType(m.contentHint(), m.contentPurpose()))
109- }
110- if m.hasSurround {
111- m.hasSurround = false
112- m.send(m.input.SetSurroundingText(m.pendingSurround, int32(m.pendingCursor), int32(m.pendingAnchor)))
113- }
114- if m.pendingRect {
115- m.pendingRect = false
116- m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3]))
117- }
118- if m.Err == nil {
119- m.commit()
120- }
121- }
122-}
123-func (m *IME) Disable() {
124- if m == nil {
125- return
126- }
127- m.requested = false
128- if !m.enabled {
129- return
130- }
131- m.send(m.input.Disable())
132- m.enabled = false
133- m.synced = true
134- m.commit()
135- m.batch = edit.IMEBatch{}
136-}
137-func (m *IME) Surrounding(text string, cursor, anchor int) {
138- if m == nil {
139- return
140- }
141- text, cursor, anchor = surroundingWindow(text, cursor, anchor)
142- if !m.enabled || !m.synced {
143- m.pendingSurround, m.pendingCursor, m.pendingAnchor = text, cursor, anchor
144- m.hasSurround = true
145- return
146- }
147- m.send(m.input.SetSurroundingText(text, int32(cursor), int32(anchor)))
148- m.commit()
149-}
150-func (m *IME) CursorRect(x, y, w, h float64) {
151- if m == nil {
152- return
153- }
154- if !validRect(x, y, w, h) {
155- return
156- }
157- m.rect = [4]int32{int32(math.Round(x)), int32(math.Round(y)), int32(math.Round(w)), int32(math.Round(h))}
158- if !m.enabled || !m.synced {
159- m.pendingRect = true
160- return
161- }
162- m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3]))
163- m.commit()
164-}
165-func validRect(x, y, w, h float64) bool {
166- for _, v := range []float64{x, y, w, h} {
167- if math.IsNaN(v) || math.IsInf(v, 0) || v < -math.MaxInt32 || v > math.MaxInt32 {
168- return false
169- }
170- }
171- return w >= 0 && h >= 0
172-}
173-func (m *IME) ContentType(password, multiline bool) {
174- if m == nil {
175- return
176- }
177- if !m.enabled || !m.synced {
178- m.pendingContent = true
179- m.password, m.multiline = password, multiline
180- return
181- }
182- m.password, m.multiline = password, multiline
183- m.send(m.input.SetContentType(m.contentHint(), m.contentPurpose()))
184- m.commit()
185-}
186-func (m *IME) contentHint() uint32 {
187- hint := uint32(textinput.CONTENT_HINT_NONE)
188- if m.password {
189- return textinput.CONTENT_HINT_SENSITIVE_DATA | textinput.CONTENT_HINT_HIDDEN_TEXT
190- }
191- if m.multiline {
192- return textinput.CONTENT_HINT_MULTILINE
193- }
194- return hint
195-}
196-func (m *IME) contentPurpose() uint32 {
197- if m.password {
198- return textinput.CONTENT_PURPOSE_PASSWORD
199- }
200- return textinput.CONTENT_PURPOSE_NORMAL
201-}
202-
203-// surroundingWindow keeps a UTF-8 aligned 4000-byte window including the
204-// cursor and as much of the selection as possible, centered near the cursor.
205-func surroundingWindow(s string, cursor, anchor int) (string, int, int) {
206- if !utf8.ValidString(s) {
207- s = ""
208- }
209- cursor = max(0, min(cursor, len(s)))
210- anchor = max(0, min(anchor, len(s)))
211- for cursor > 0 && cursor < len(s) && !utf8.RuneStart(s[cursor]) {
212- cursor--
213- }
214- for anchor > 0 && anchor < len(s) && !utf8.RuneStart(s[anchor]) {
215- anchor--
216- }
217- if len(s) <= 4000 {
218- return s, cursor, anchor
219- }
220- start := max(0, cursor-2000)
221- if anchor < cursor && cursor-anchor <= 4000 {
222- start = min(start, anchor)
223- }
224- if anchor > cursor && anchor-cursor <= 4000 {
225- start = max(start, anchor-4000)
226- }
227- start = min(start, len(s)-4000)
228- for start < len(s) && !utf8.RuneStart(s[start]) {
229- start++
230- }
231- end := min(len(s), start+4000)
232- for end > start && end < len(s) && !utf8.RuneStart(s[end]) {
233- end--
234- }
235- cursor -= start
236- anchor = max(0, min(anchor-start, end-start))
237- return s[start:end], cursor, anchor
238-}
239-
240-// ApplyIME buffers reader events until done. The serial is the compositor's
241-// count of client commit requests, not a keyboard serial. Per text-input-v3,
242-// even a mismatched serial must apply the edit batch as normal; it only
243-// prohibits changing protocol state until a matching done is received.
244-func (m *IME) ApplyIME(ev IMEEvent) (edit.IMEBatch, bool) {
245- if m == nil {
246- return edit.IMEBatch{}, false
247- }
248- switch ev.Kind {
249- case "enter":
250- if ev.Surface == m.surface {
251- m.entered = true
252- m.synced = true
253- m.batch = edit.IMEBatch{}
254- if m.requested {
255- m.enabled = false
256- m.Enable()
257- }
258- }
259- case "leave":
260- if ev.Surface == m.surface {
261- m.entered = false
262- m.enabled = false
263- m.synced = false
264- m.batch = edit.IMEBatch{}
265- }
266- case "preedit":
267- if !m.entered || !m.enabled {
268- return edit.IMEBatch{}, false
269- }
270- m.batch.Preedit = ev.Text
271- m.batch.Begin = int(ev.Begin)
272- m.batch.End = int(ev.End)
273- case "commit":
274- if !m.entered || !m.enabled {
275- return edit.IMEBatch{}, false
276- }
277- m.batch.Commit = ev.Text
278- case "delete":
279- if !m.entered || !m.enabled {
280- return edit.IMEBatch{}, false
281- }
282- m.batch.DeleteBefore = int(min(ev.Before, 4000))
283- m.batch.DeleteAfter = int(min(ev.After, 4000))
284- case "done":
285- batch := m.batch
286- m.batch = edit.IMEBatch{}
287- if !m.entered || !m.enabled {
288- return edit.IMEBatch{}, false
289- }
290- m.synced = ev.Serial == m.commits
291- if m.synced {
292- m.flushPending()
293- }
294- return batch, true
295- }
296- return edit.IMEBatch{}, false
297-}
298-
299-// flushPending sends state only after a matching done. Mismatched done batches
300-// still reach the editor, but do not modify the text-input object's state.
301-func (m *IME) flushPending() {
302- if m.pendingContent {
303- m.pendingContent = false
304- m.ContentType(m.password, m.multiline)
305- }
306- if m.hasSurround {
307- s, c, a := m.pendingSurround, m.pendingCursor, m.pendingAnchor
308- m.hasSurround = false
309- m.Surrounding(s, c, a)
310- }
311- if m.pendingRect {
312- m.pendingRect = false
313- m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3]))
314- m.commit()
315- }
316-}
317-func (m *IME) Close() {
318- if m == nil {
319- return
320- }
321- if m.input != nil {
322- _ = m.input.Destroy()
323- m.input = nil
324- }
325- if m.manager != nil {
326- _ = m.manager.Destroy()
327- m.manager = nil
328- }
329-}
D internal/platform/wayland/ime.md +0 -28
@@ -1,28 +0,0 @@
1-# text-input v3 adapter
2-
3-`NewIME` optionally binds `zwp_text_input_manager_v3` and creates one
4-text-input object per seat. `nil` means unavailable: ordinary xkb text remains
5-active. A session posts reader callbacks as `IMEEvent`s, invokes `ApplyIME` on
6-the UI loop, and passes only the completed `edit.IMEBatch` to the editor.
7-Ordinary xkb text stays active while text-input is enabled, so enabling it
8-without a running input method does not block typing. An input method that
9-grabs the keyboard receives keys instead of `wl_keyboard`. For an input method
10-that does not grab, keys reaching `wl_keyboard` are typed except while its
11-preedit is shown; the input method owns that text until it commits.
12-`Enable` requested before `enter` is deferred until the
13-surface gains text-input focus; leave drops the pending composition.
14-
15-Every successfully sent `zwp_text_input_v3.commit` increments the per-object
16-commit count. The protocol's `done.serial` is this **count**, not the keyboard
17-serial. The text-input-v3 XML says a mismatched done still applies its preedit,
18-commit and delete batch normally, but the client must not modify the protocol
19-object's current state; pending surrounding/content/rectangle updates are
20-held until a matching done arrives. `ApplyIME` implements this rule. Requests
21-are double-buffered by protocol commits. The cursor rectangle is surface-local
22-logical pixels; with negotiated v2 it is applied on the next surface commit.
23-A password sends sensitive+hidden hints, password purpose, and empty
24-surrounding text (the editor owns that privacy decision).
25-
26-Surrounding text is limited to 4000 UTF-8 bytes and clipped around the cursor
27-at rune boundaries. Reader callbacks never touch editor state. NeferWL does
28-not expose text-input-v3; tests use a socketpair fake server only.
D internal/platform/wayland/ime_test.go +0 -187
@@ -1,187 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "encoding/binary"
7- "strings"
8- "testing"
9- "time"
10- "unicode/utf8"
11-
12- "github.com/bnema/wlturbo"
13- "github.com/bnema/wlturbo/protocol/core"
14- "github.com/bnema/wlturbo/protocol/textinput"
15-)
16-
17-func TestSurroundingUTF8Window(t *testing.T) {
18- s := strings.Repeat("é", 3000)
19- for _, pos := range []int{0, 1, 2000, 3999, len(s)} {
20- text, cursor, anchor := surroundingWindow(s, pos, pos)
21- if len(text) > 4000 || !strings.Contains(s, text) || cursor < 0 || cursor > len(text) || anchor != cursor {
22- t.Fatalf("window at %d: len=%d cursor=%d", pos, len(text), cursor)
23- }
24- }
25-}
26-func imeStringFrame(id uint32, opcode uint16, s string, tail ...uint32) []byte {
27- data := append([]byte(s), 0)
28- padded := make([]byte, (len(data)+3)&^3)
29- copy(padded, data)
30- frame := inputFrame(id, opcode, uint32(len(data)))
31- frame = append(frame, padded...)
32- for _, v := range tail {
33- b := make([]byte, 4)
34- binary.LittleEndian.PutUint32(b, v)
35- frame = append(frame, b...)
36- }
37- binary.LittleEndian.PutUint32(frame[4:], uint32(len(frame))<<16|uint32(opcode))
38- return frame
39-}
40-func TestIMEBatchSerialOverSocketpair(t *testing.T) {
41- ca, server := inputPair(t)
42- d, err := wlturbo.ConnectFromConn(ca)
43- if err != nil {
44- t.Fatal(err)
45- }
46- defer d.Close()
47- inputRequest(t, server)
48- manager := textinput.NewTextInputManagerV3(d.Context())
49- if err = d.Registry().Bind(1, textinput.TextInputManagerV3Interface, 2, manager); err != nil {
50- t.Fatal(err)
51- }
52- inputRequest(t, server)
53- seat := core.NewSeat(d.Context())
54- seat.SetID(20)
55- d.Context().Register(seat)
56- surface := core.NewSurface(d.Context())
57- surface.SetID(30)
58- d.Context().Register(surface)
59- input, err := manager.GetTextInput(seat)
60- if err != nil {
61- t.Fatal(err)
62- }
63- inputRequest(t, server)
64- events := make(chan IMEEvent, 10)
65- m := &IME{input: input, surface: surface.ID(), post: func(e IMEEvent) { events <- e }}
66- input.OnEnter(func(id uint32) { m.post(IMEEvent{Kind: "enter", Surface: id}) })
67- input.OnPreeditString(func(s string, b, e int32) { m.post(IMEEvent{Kind: "preedit", Text: s, Begin: b, End: e}) })
68- input.OnCommitString(func(s string) { m.post(IMEEvent{Kind: "commit", Text: s}) })
69- input.OnDeleteSurroundingText(func(b, a uint32) { m.post(IMEEvent{Kind: "delete", Before: b, After: a}) })
70- input.OnDone(func(serial uint32) { m.post(IMEEvent{Kind: "done", Serial: serial}) })
71- m.Enable() // requests before surface enter are remembered
72- inputSend(t, server, inputFrame(input.ID(), 0, surface.ID()))
73- if err = d.Dispatch(); err != nil {
74- t.Fatal(err)
75- }
76- m.ApplyIME(<-events)
77- if m.commits != 1 || !m.enabled {
78- t.Fatalf("enable %d %v", m.commits, m.enabled)
79- }
80- inputRequest(t, server) // enable; commit might be in the same read
81- for _, msg := range [][]byte{
82- imeStringFrame(input.ID(), 2, "é", 0, 2),
83- imeStringFrame(input.ID(), 3, "x"),
84- inputFrame(input.ID(), 4, 1, 0),
85- inputFrame(input.ID(), 5, 0), // mismatched serial MUST still apply edit
86- } {
87- inputSend(t, server, msg)
88- if err = d.Dispatch(); err != nil {
89- t.Fatal(err)
90- }
91- }
92- for i := 0; i < 3; i++ {
93- if _, ok := m.ApplyIME(<-events); ok {
94- t.Fatal("partial batch delivered")
95- }
96- }
97- batch, ok := m.ApplyIME(<-events)
98- if !ok || batch.Preedit != "é" || batch.Commit != "x" || batch.DeleteBefore != 1 || m.synced {
99- t.Fatalf("batch %+v ok=%v synced=%v", batch, ok, m.synced)
100- }
101- m.ContentType(true, false)
102- if !m.pendingContent {
103- t.Fatal("mismatched done changed protocol state")
104- }
105- m.ApplyIME(IMEEvent{Kind: "done", Serial: 1})
106- if !m.synced || m.commits <= 1 {
107- t.Fatal("pending state not flushed after matching done")
108- }
109-}
110-
111-func TestSurroundingWindowRuneBoundaries(t *testing.T) {
112- s := strings.Repeat("é", 3500)
113- for _, pos := range []int{0, 1, 3999, len(s) - 1, len(s)} {
114- window, cursor, anchor := surroundingWindow(s, pos, len(s))
115- if len(window) > 4000 || !utf8.ValidString(window) || !utf8.ValidString(window[:cursor]) || !utf8.ValidString(window[:anchor]) {
116- t.Fatalf("invalid window at %d: len=%d cursor=%d anchor=%d", pos, len(window), cursor, anchor)
117- }
118- }
119-}
120-
121-func TestIMEPasswordContentWire(t *testing.T) {
122- ca, server := inputPair(t)
123- d, err := wlturbo.ConnectFromConn(ca)
124- if err != nil {
125- t.Fatal(err)
126- }
127- defer d.Close()
128- inputRequest(t, server)
129- input := textinput.NewTextInputV3(d.Context())
130- input.SetID(20)
131- d.Context().Register(input)
132- m := &IME{input: input, entered: true, enabled: true, synced: true}
133- m.ContentType(true, true)
134- buf := make([]byte, 128)
135- _ = server.SetReadDeadline(time.Now().Add(time.Second))
136- n, err := server.Read(buf)
137- if err != nil {
138- t.Fatal(err)
139- }
140- if n < 16 || binary.LittleEndian.Uint32(buf[:4]) != input.ID() || binary.LittleEndian.Uint16(buf[4:]) != 5 {
141- t.Fatalf("unexpected content request: %x", buf[:n])
142- }
143- hint := binary.LittleEndian.Uint32(buf[8:12])
144- purpose := binary.LittleEndian.Uint32(buf[12:16])
145- if hint != textinput.CONTENT_HINT_SENSITIVE_DATA|textinput.CONTENT_HINT_HIDDEN_TEXT || purpose != textinput.CONTENT_PURPOSE_PASSWORD {
146- t.Fatalf("password hint=%d purpose=%d", hint, purpose)
147- }
148-}
149-
150-func TestIMEInitialStateOneCommit(t *testing.T) {
151- ca, server := inputPair(t)
152- d, err := wlturbo.ConnectFromConn(ca)
153- if err != nil {
154- t.Fatal(err)
155- }
156- defer d.Close()
157- inputRequest(t, server)
158- input := textinput.NewTextInputV3(d.Context())
159- input.SetID(20)
160- d.Context().Register(input)
161- m := &IME{input: input, entered: true}
162- m.Surrounding("hello", 5, 5)
163- m.ContentType(true, false)
164- m.CursorRect(1, 2, 3, 4)
165- m.Enable()
166- if m.Err != nil || m.commits != 1 {
167- t.Fatalf("enable: %v commits=%d", m.Err, m.commits)
168- }
169- buf := make([]byte, 256)
170- _ = server.SetReadDeadline(time.Now().Add(time.Second))
171- n, err := server.Read(buf)
172- if err != nil {
173- t.Fatal(err)
174- }
175- var ops []uint16
176- for offset := 0; offset < n; {
177- size := int(binary.LittleEndian.Uint16(buf[offset+6:]))
178- if size < 8 || offset+size > n {
179- t.Fatalf("invalid frame: %x", buf[:n])
180- }
181- ops = append(ops, binary.LittleEndian.Uint16(buf[offset+4:]))
182- offset += size
183- }
184- if len(ops) != 5 || ops[0] != 1 || ops[1] != 5 || ops[2] != 3 || ops[3] != 6 || ops[4] != 7 {
185- t.Fatalf("request order %v", ops)
186- }
187-}
D internal/platform/wayland/input.go +0 -458
@@ -1,458 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "fmt"
7- "os"
8- "time"
9-
10- "github.com/bnema/nefergui/internal/keyboard"
11- "github.com/bnema/wlturbo/protocol/core"
12- "github.com/bnema/wlturbo/wl"
13-)
14-
15-// InputReset tells the runtime to synthesize pointer leave, release all buttons,
16-// keyboard focus-out and repeat cancellation before processing later inputs.
17-const InputReset = "reset"
18-
19-// Input is the platform-to-UI port. wl_surface local fixed coordinates are
20-// already logical pixels and must not be divided by buffer scale.
21-type Input struct {
22- Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, reset
23- X, Y, DX, DY float64
24- Button, Serial, Axis, AxisSource uint32
25- AxisStop bool
26- Enter bool // motion from wl_pointer.enter; Serial is the enter serial
27- PointerGen uint64 // wl_pointer generation that sent Enter
28- Value120 int32
29- Key keyboard.Key
30-}
31-
32-// InputAdapter is owned by the session loop. Never call ApplyInput on the reader.
33-// The session loop also owns the repeat timer (Tick and NextRepeat).
34-type InputAdapter struct {
35- Keyboard *keyboard.Keyboard
36- serial uint32
37- focused bool
38-}
39-
40-// resetHeld drops held keys, repeat and compose state after InputReset while
41-// keeping keyboard focus, so later key events still apply without a new enter.
42-func (a *InputAdapter) resetHeld() {
43- if a != nil && a.Keyboard != nil {
44- a.Keyboard.Focus(a.focused)
45- }
46-}
47-
48-func NewInputAdapter(locale string) (*InputAdapter, error) {
49- k, err := keyboard.New(locale)
50- if err != nil {
51- return nil, err
52- }
53- k.Focus(false)
54- return &InputAdapter{Keyboard: k}, nil
55-}
56-func (a *InputAdapter) Close() {
57- if a != nil && a.Keyboard != nil {
58- a.Keyboard.Close()
59- }
60-}
61-func (a *InputAdapter) SelectionSerial() uint32 { return a.serial }
62-func (a *InputAdapter) Tick(now time.Time) (Input, bool) {
63- key, ok := a.Keyboard.Tick(now)
64- return Input{Kind: "key", Key: key}, ok
65-}
66-func (a *InputAdapter) NextRepeat() (time.Time, bool) { return a.Keyboard.NextRepeat() }
67-
68-// InputAdapter returns the loop-owned interpreter; the reader never calls it.
69-func (w *Window) InputAdapter() *InputAdapter { return w.inputAdapter }
70-
71-// ApplyInput consumes the raw event including its keymap FD, on every path.
72-func (a *InputAdapter) ApplyInput(ev Event, now time.Time) ([]Input, error) {
73- switch ev.Kind {
74- case InputFrame:
75- return ev.Inputs, nil
76- case InputKeymap:
77- if ev.FD < 0 {
78- return nil, fmt.Errorf("missing keymap fd")
79- }
80- if ev.Format != 1 || ev.Size == 0 || ev.Size > 16<<20 {
81- CloseEventFD(ev)
82- return nil, fmt.Errorf("unsupported keymap format or size")
83- }
84- return nil, a.Keyboard.ReplaceFD(ev.FD, int(ev.Size))
85- case InputFocusIn:
86- a.Keyboard.Focus(true)
87- a.focused = true
88- a.serial = ev.Serial
89- return []Input{{Kind: "focus-in", Serial: ev.Serial}}, nil
90- case InputFocusOut:
91- a.Keyboard.Focus(false)
92- a.focused = false
93- a.serial = 0
94- return []Input{{Kind: "focus-out"}}, nil
95- case InputKey:
96- if ev.Pressed && a.Keyboard != nil {
97- a.serial = ev.Serial
98- }
99- key, err := a.Keyboard.Event(ev.Code, ev.Pressed, now)
100- if err != nil || key.Physical == 0 {
101- return nil, err
102- }
103- return []Input{{Kind: "key", Key: key, Serial: ev.Serial}}, nil
104- case InputModifiers:
105- return nil, a.Keyboard.Mask(ev.Mods[0], ev.Mods[1], ev.Mods[2], 0, 0, ev.Mods[3])
106- case InputRepeatInfo:
107- a.Keyboard.RepeatInfo(int(ev.Rate), time.Duration(ev.Delay)*time.Millisecond)
108- return nil, nil
109- }
110- return nil, fmt.Errorf("not an input event: %d", ev.Kind)
111-}
112-
113-// CloseEventFD releases a keymap event not delivered to the loop, including FD 0.
114-func CloseEventFD(ev Event) {
115- if ev.Kind == InputKeymap && ev.FD >= 0 {
116- _ = os.NewFile(uintptr(ev.FD), "keymap").Close()
117- }
118-}
119-
120-// BindSeat binds the optional initial seat. The reader posts capability changes
121-// and global removal; ApplySeat runs exclusively on the session loop.
122-func (w *Window) BindSeat() error {
123- seat := core.NewSeat(w.Display.Context())
124- g, ok := w.Display.Registry().FindGlobal(core.SeatInterface)
125- if !ok {
126- w.Display.Context().Unregister(seat)
127- return nil
128- }
129- version, err := w.Display.Registry().BindNegotiated(core.SeatInterface, 9, seat)
130- if err != nil {
131- w.Display.Context().Unregister(seat)
132- return err
133- }
134- w.Seat, w.seatName, w.seatVersion = seat, g.Name, version
135- seat.OnCapabilities(func(c uint32) { w.post(Event{Kind: SeatCapabilities, Capabilities: c}) })
136- w.Display.Registry().AddGlobalRemoveHandler(seatRemoved{w: w, name: g.Name})
137- return nil
138-}
139-
140-type seatRemoved struct {
141- w *Window
142- name uint32
143-}
144-
145-func (r seatRemoved) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) {
146- if e.Name == r.name {
147- r.w.post(Event{Kind: SeatRemoved})
148- }
149-}
150-func (w *Window) ApplySeat(ev Event) error {
151- switch ev.Kind {
152- case SeatRemoved:
153- if w.WLKeyboard != nil && w.connection == nil {
154- w.ownerFocusOut()
155- }
156- return w.releaseSeat()
157- case SeatCapabilities:
158- if w.Seat == nil {
159- return nil
160- }
161- if ev.Capabilities&1 == 0 && w.Pointer != nil {
162- w.dropCursorDevice()
163- w.releaseDevice(w.Pointer)
164- w.Pointer = nil
165- }
166- if ev.Capabilities&2 == 0 && w.WLKeyboard != nil {
167- w.releaseDevice(w.WLKeyboard)
168- w.WLKeyboard = nil
169- if w.keymapCache != nil {
170- w.keymapCache.close()
171- w.keymapCache = nil
172- }
173- if w.connection == nil {
174- w.ownerFocusOut()
175- }
176- }
177- if ev.Capabilities&1 != 0 && w.Pointer == nil {
178- p, err := w.Seat.GetPointer()
179- if err != nil {
180- return err
181- }
182- w.Pointer = p
183- w.watchPointer(p)
184- }
185- if ev.Capabilities&2 != 0 && w.WLKeyboard == nil {
186- k, err := w.Seat.GetKeyboard()
187- if err != nil {
188- return err
189- }
190- w.WLKeyboard = k
191- w.watchKeyboard(k)
192- }
193- return nil
194- }
195- return fmt.Errorf("not a seat event: %d", ev.Kind)
196-}
197-
198-// releaseDevice destroys a wl_pointer or wl_keyboard. Their release request
199-// exists since seat version 3; older seats only forget the proxy locally.
200-func (w *Window) releaseDevice(p interface {
201- wl.Proxy
202- Release() error
203-}) {
204- if w.seatVersion >= 3 {
205- _ = p.Release()
206- } else if c := p.Context(); c != nil {
207- c.Unregister(p)
208- }
209-}
210-
211-// TakeInputEvents transfers lock-only raw input ownership, including keymap
212-// FDs. The secret owner must close each FD after consuming it. No Text/Key
213-// translation occurs here; ordinary windows continue to use Inputs().
214-func (w *Window) TakeInputEvents() []Event {
215- events := w.rawInput
216- w.rawInput = nil
217- return events
218-}
219-
220-func (w *Window) ownerFocusOut() {
221- if w.LockSurface != nil {
222- for _, ev := range w.rawInput {
223- CloseEventFD(ev)
224- }
225- clear(w.rawInput)
226- w.rawInput = append(w.rawInput[:0], Event{Kind: InputFocusOut, Window: w})
227- return
228- }
229- if w.inputAdapter != nil {
230- w.inputAdapter.Keyboard.Focus(false)
231- w.inputAdapter.focused = false
232- w.inputAdapter.serial = 0
233- }
234- w.queueInput([]Input{{Kind: "focus-out"}})
235-}
236-
237-func (w *Window) releaseSeat() error {
238- if w.keymapCache != nil {
239- w.keymapCache.close()
240- w.keymapCache = nil
241- }
242- if w.Pointer != nil {
243- w.dropCursorDevice()
244- w.releaseDevice(w.Pointer)
245- w.Pointer = nil
246- }
247- if w.WLKeyboard != nil {
248- w.releaseDevice(w.WLKeyboard)
249- w.WLKeyboard = nil
250- }
251- if w.Seat != nil {
252- if w.seatVersion >= 5 {
253- _ = w.Seat.Release()
254- } else {
255- w.Display.Context().Unregister(w.Seat)
256- }
257- w.Seat = nil
258- }
259- return nil
260-}
261-func (w *Window) watchPointer(p *core.Pointer) {
262- if w.connection != nil {
263- w.watchSharedPointer(p)
264- return
265- }
266- var frame []Input // only accessed by the reader
267- var x, y float64
268- var inside bool
269- var source uint32
270- // The generation tells a late enter from a released pointer apart from
271- // the current one; object IDs can be reused.
272- w.pointerGen++
273- surfaceID, version, gen := w.Surface.ID(), w.seatVersion, w.pointerGen
274- flush := func() {
275- if len(frame) > 0 {
276- w.post(Event{Kind: InputFrame, Inputs: frame})
277- frame = nil
278- }
279- source = 0
280- }
281- add := func(i Input) {
282- i.AxisSource = source
283- frame = append(frame, i)
284- if version < 5 {
285- flush()
286- }
287- }
288- p.OnEnter(func(serial uint32, id uint32, px, py wl.Fixed) {
289- if id != surfaceID {
290- return
291- }
292- inside = true
293- x, y = px.Float64(), py.Float64()
294- add(Input{Kind: "motion", X: x, Y: y, Enter: true, Serial: serial, PointerGen: gen})
295- })
296- p.OnLeave(func(_ uint32, id uint32) {
297- if id != surfaceID {
298- return
299- }
300- inside = false
301- add(Input{Kind: "leave"})
302- })
303- p.OnMotion(func(_ uint32, px, py wl.Fixed) {
304- if inside {
305- x, y = px.Float64(), py.Float64()
306- add(Input{Kind: "motion", X: x, Y: y})
307- }
308- })
309- p.OnButton(func(serial, _, button, state uint32) {
310- if !inside && state == 1 {
311- return
312- }
313- kind := "release"
314- if state == 1 {
315- kind = "press"
316- }
317- add(Input{Kind: kind, X: x, Y: y, Button: button, Serial: serial})
318- })
319- p.OnAxisSource(func(s uint32) { source = s })
320- p.OnAxis(func(_, axis uint32, v wl.Fixed) {
321- if inside {
322- i := Input{Kind: "axis", X: x, Y: y, Axis: axis}
323- if axis == 0 {
324- i.DY = v.Float64()
325- } else {
326- i.DX = v.Float64()
327- }
328- add(i)
329- }
330- })
331- p.OnAxisValue120(func(axis uint32, v int32) {
332- if inside {
333- add(Input{Kind: "axis", X: x, Y: y, Axis: axis, Value120: v})
334- }
335- })
336- p.OnAxisStop(func(_, axis uint32) {
337- if inside {
338- add(Input{Kind: "axis", X: x, Y: y, Axis: axis, AxisStop: true})
339- }
340- })
341- p.OnFrame(flush)
342-}
343-func (w *Window) watchKeyboard(k *core.Keyboard) {
344- if w.connection != nil {
345- w.watchSharedKeyboard(k)
346- return
347- }
348- surfaceID := w.Surface.ID()
349- k.OnKeymap(func(format uint32, fd *wl.OwnedFD, size uint32) {
350- n, err := fd.Take()
351- if err != nil {
352- w.post(Event{Kind: TransportError, Err: err})
353- return
354- }
355- w.postFD(Event{Kind: InputKeymap, FD: n, Format: format, Size: size})
356- })
357- k.OnEnter(func(serial, id uint32, _ []byte) {
358- if id == surfaceID {
359- w.post(Event{Kind: InputFocusIn, Serial: serial})
360- }
361- })
362- k.OnLeave(func(_, id uint32) {
363- if id == surfaceID {
364- w.post(Event{Kind: InputFocusOut})
365- }
366- })
367- k.OnKey(func(serial, _, key, state uint32) {
368- w.post(Event{Kind: InputKey, Serial: serial, Code: key, Pressed: state == 1})
369- })
370- k.OnModifiers(func(_, depressed, latched, locked, group uint32) {
371- w.post(Event{Kind: InputModifiers, Mods: [4]uint32{depressed, latched, locked, group}})
372- })
373- k.OnRepeatInfo(func(rate, delay int32) { w.post(Event{Kind: InputRepeatInfo, Rate: rate, Delay: delay}) })
374-}
375-
376-// InputEvent is a loop-owned input value. Runtime integration drains these later.
377-type InputEvent = Input
378-
379-// DrainInput appends pending input in order to dst and clears the queue.
380-// The queue holds at most 4096 events. Motion is evicted first, compatible
381-// adjacent axes are merged; if neither is possible it emits InputReset and
382-// starts a new queue. InputOverflow counts discarded events. Call on the Apply loop.
383-func (w *Window) DrainInput(dst []InputEvent) []InputEvent {
384- dst = append(dst, w.inputQueue...)
385- clear(w.inputQueue)
386- w.inputQueue = w.inputQueue[:0]
387- return dst
388-}
389-
390-func mergeAxis(dst *Input, src Input) bool {
391- if dst.Kind != "axis" || src.Kind != "axis" || dst.Axis != src.Axis ||
392- dst.AxisSource != src.AxisSource || dst.AxisStop || src.AxisStop {
393- return false
394- }
395- dst.DX += src.DX
396- dst.DY += src.DY
397- dst.Value120 += src.Value120
398- dst.X, dst.Y = src.X, src.Y
399- return true
400-}
401-
402-func (w *Window) queueInput(inputs []Input) {
403- for _, input := range inputs {
404- if len(w.inputQueue) == 4096 {
405- index := -1
406- for i, v := range w.inputQueue {
407- // An enter motion carries the serial cursor shapes need.
408- if v.Kind == "motion" && !v.Enter {
409- index = i
410- break
411- }
412- }
413- if index < 0 {
414- // Adjacent axes on the same axis/source can be combined without
415- // crossing a button or key event or losing scroll distance.
416- for i := 0; i+1 < len(w.inputQueue); i++ {
417- if mergeAxis(&w.inputQueue[i], w.inputQueue[i+1]) {
418- index = i + 1
419- break
420- }
421- }
422- }
423- if index >= 0 {
424- if w.inputQueue[index].Kind == "motion" {
425- w.InputOverflow++
426- }
427- copy(w.inputQueue[index:], w.inputQueue[index+1:])
428- w.inputQueue = w.inputQueue[:len(w.inputQueue)-1]
429- } else {
430- // A previous reset is still pending if the consumer has not
431- // drained yet; keep one marker across successive overflows.
432- dropped := len(w.inputQueue)
433- if w.inputQueue[0].Kind == InputReset {
434- dropped--
435- }
436- w.InputOverflow += uint64(dropped)
437- // Keep the latest discarded enter or leave: it still decides
438- // whether the pointer is inside, and an enter carries the serial
439- // cursor shapes need.
440- var focus *Input
441- for i := range w.inputQueue {
442- if v := w.inputQueue[i]; v.Enter || v.Kind == "leave" {
443- focus = &v
444- }
445- }
446- clear(w.inputQueue)
447- w.inputQueue = w.inputQueue[:0]
448- w.inputQueue = append(w.inputQueue, Input{Kind: InputReset})
449- if focus != nil {
450- w.inputQueue = append(w.inputQueue, *focus)
451- }
452- // Discarded presses must not keep repeating on the loop.
453- w.inputAdapter.resetHeld()
454- }
455- }
456- w.inputQueue = append(w.inputQueue, input)
457- }
458-}
D internal/platform/wayland/input.md +0 -32
@@ -1,32 +0,0 @@
1-# Wayland input boundary
2-
3-`Connect` binds the optional initial `wl_seat`. `StartReader` owns all Wayland
4-callbacks and posts typed `Event` values. The session loop must call `ApplySeat`
5-for capability/removal events, `InputAdapter.ApplyInput` for input events, and
6-translate resulting `Input` values to the root `platformInput` later, at the
7-runtime wiring boundary. Callbacks never run the editor, keyboard interpreter,
8-or UI. On shutdown, `Window.Close` closes unconsumed keymap FDs in its queues.
9-A consumer that drops an event before dispatch must use `CloseEventFD`.
10-
11-`wl_pointer` surface-local fixed-point coordinates are **already logical**;
12-do not apply window buffer scale. A version 5+ pointer frame becomes one ordered
13-`InputFrame`. For older pointers events flush individually. Axis deltas preserve
14-their native Wayland units; `value120`, stop, and source metadata are additional
15-axis events, not extra pixel scroll deltas. Consumers must avoid counting both
16-axis and value120 for the same frame. A button release outside the surface is
17-still delivered, for cancellation of pointer capture.
18-
19-The bounded input queue evicts old motion first, then coalesces adjacent axis
20-values on the same axis/source (without crossing discrete events). If still full,
21-it discards the queued state and emits one `InputReset` (`Kind == "reset"`)
22-before later events. The runtime must treat reset as pointer leave + release of
23-all buttons + keyboard focus-out/cancel repeat, then re-sync from later events.
24-The adapter itself drops held keys and repeat on reset but keeps compositor
25-keyboard focus, so later key events apply without a new enter.
26-`InputOverflow` counts discarded inputs; merging lossless axes does not count.
27-
28-A keymap event transfers its FD via `OwnedFD.Take()` from the reader to exactly
29-one loop event; `ApplyInput` passes it to `keyboard.ReplaceFD`, which closes it
30-on success or error. Repeat info and focus loss are applied only on the loop.
31-The compositor serial from keyboard enter or key press is exposed for selection
32-ownership. `Tick` and `NextRepeat` belong to that same loop, never the reader.
D internal/platform/wayland/input_test.go +0 -359
@@ -1,359 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "encoding/binary"
7- "net"
8- "os"
9- "testing"
10- "time"
11-
12- "github.com/bnema/wlturbo"
13- "github.com/bnema/wlturbo/protocol/core"
14- "golang.org/x/sys/unix"
15-)
16-
17-func inputPair(t *testing.T) (*net.UnixConn, *net.UnixConn) {
18- t.Helper()
19- f, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
20- if err != nil {
21- t.Fatal(err)
22- }
23- a := os.NewFile(uintptr(f[0]), "client")
24- b := os.NewFile(uintptr(f[1]), "server")
25- ca, e := net.FileConn(a)
26- _ = a.Close()
27- if e != nil {
28- t.Fatal(e)
29- }
30- cb, e := net.FileConn(b)
31- _ = b.Close()
32- if e != nil {
33- t.Fatal(e)
34- }
35- t.Cleanup(func() { _ = ca.Close(); _ = cb.Close() })
36- return ca.(*net.UnixConn), cb.(*net.UnixConn)
37-}
38-func inputFrame(id uint32, op uint16, body ...uint32) []byte {
39- b := make([]byte, 8+len(body)*4)
40- binary.LittleEndian.PutUint32(b, id)
41- binary.LittleEndian.PutUint32(b[4:], uint32(len(b))<<16|uint32(op))
42- for i, v := range body {
43- binary.LittleEndian.PutUint32(b[8+i*4:], v)
44- }
45- return b
46-}
47-func inputSend(t *testing.T, p *net.UnixConn, msg []byte, fd ...int) {
48- t.Helper()
49- var oob []byte
50- if len(fd) > 0 {
51- oob = unix.UnixRights(fd...)
52- }
53- if _, _, err := p.WriteMsgUnix(msg, oob, nil); err != nil {
54- t.Fatal(err)
55- }
56-}
57-func inputRequest(t *testing.T, p *net.UnixConn) {
58- t.Helper()
59- _ = p.SetReadDeadline(time.Now().Add(time.Second))
60- b := make([]byte, 256)
61- if _, err := p.Read(b); err != nil {
62- t.Fatal(err)
63- }
64-}
65-func TestPointerFrameOverSocketpair(t *testing.T) {
66- ca, server := inputPair(t)
67- d, err := wlturbo.ConnectFromConn(ca)
68- if err != nil {
69- t.Fatal(err)
70- }
71- defer d.Close()
72- inputRequest(t, server)
73- ctx := d.Context()
74- seat := core.NewSeat(ctx)
75- if err = d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil {
76- t.Fatal(err)
77- }
78- inputRequest(t, server)
79- p, err := seat.GetPointer()
80- if err != nil {
81- t.Fatal(err)
82- }
83- inputRequest(t, server)
84- surface := core.NewSurface(ctx)
85- surface.SetID(30)
86- ctx.Register(surface)
87- w := &Window{Surface: surface, seatVersion: 9, events: make(chan Event, 10), readerStop: make(chan struct{})}
88- w.watchPointer(p)
89- for _, msg := range [][]byte{
90- inputFrame(p.ID(), 0, 1, 30, 256*12, 256*7), // enter: surface-local logical pixels
91- inputFrame(p.ID(), 2, 2, 256*14, 256*8),
92- inputFrame(p.ID(), 6, 1), // wheel source
93- inputFrame(p.ID(), 4, 3, 0, 256*5),
94- inputFrame(p.ID(), 9, 0, 120),
95- inputFrame(p.ID(), 3, 4, 4, 0x110, 1),
96- inputFrame(p.ID(), 5),
97- } {
98- inputSend(t, server, msg)
99- if err = d.Dispatch(); err != nil {
100- t.Fatal(err)
101- }
102- }
103- select {
104- case ev := <-w.events:
105- if ev.Kind != InputFrame || len(ev.Inputs) != 5 {
106- t.Fatalf("frame: %+v", ev)
107- }
108- if !ev.Inputs[0].Enter || ev.Inputs[0].Serial != 1 || ev.Inputs[0].PointerGen != w.pointerGen || ev.Inputs[0].X != 12 || ev.Inputs[1].X != 14 || ev.Inputs[2].DY != 5 || ev.Inputs[2].AxisSource != 1 || ev.Inputs[3].Value120 != 120 || ev.Inputs[4].Kind != "press" {
109- t.Fatalf("inputs: %+v", ev.Inputs)
110- }
111- default:
112- t.Fatal("missing pointer frame")
113- }
114-}
115-func TestKeyboardFDAndRepeatOverSocketpair(t *testing.T) {
116- ca, server := inputPair(t)
117- d, err := wlturbo.ConnectFromConn(ca)
118- if err != nil {
119- t.Fatal(err)
120- }
121- defer d.Close()
122- inputRequest(t, server)
123- ctx := d.Context()
124- seat := core.NewSeat(ctx)
125- if err = d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil {
126- t.Fatal(err)
127- }
128- inputRequest(t, server)
129- k, err := seat.GetKeyboard()
130- if err != nil {
131- t.Fatal(err)
132- }
133- inputRequest(t, server)
134- surface := core.NewSurface(ctx)
135- surface.SetID(30)
136- ctx.Register(surface)
137- w := &Window{Surface: surface, events: make(chan Event, 10), readerStop: make(chan struct{})}
138- w.watchKeyboard(k)
139- f, err := os.CreateTemp(t.TempDir(), "keymap")
140- if err != nil {
141- t.Fatal(err)
142- }
143- defer f.Close()
144- inputSend(t, server, inputFrame(k.ID(), 0, 0, 5), int(f.Fd()))
145- if err = d.Dispatch(); err != nil {
146- t.Fatal(err)
147- }
148- ev := <-w.events
149- if ev.Kind != InputKeymap || ev.FD < 0 {
150- t.Fatalf("keymap: %+v", ev)
151- }
152- adapter, err := NewInputAdapter("")
153- if err != nil {
154- t.Skipf("xkb unavailable: %v", err)
155- }
156- defer adapter.Close()
157- if _, err = adapter.ApplyInput(ev, time.Now()); err == nil {
158- t.Fatal("unsupported keymap accepted")
159- }
160- if err = unix.Fstat(ev.FD, &unix.Stat_t{}); err != unix.EBADF {
161- t.Fatalf("fd not closed: %v", err)
162- }
163- inputSend(t, server, inputFrame(k.ID(), 5, 0, 200))
164- if err = d.Dispatch(); err != nil {
165- t.Fatal(err)
166- }
167- if _, err = adapter.ApplyInput(<-w.events, time.Now()); err != nil {
168- t.Fatal(err)
169- }
170- if _, ok := adapter.NextRepeat(); ok {
171- t.Fatal("repeat should be disabled")
172- }
173-}
174-
175-// Each keymap path transfers ownership to the adapter or the discarded-event
176-// cleanup, including a valid XKB keymap and malformed compositor payloads.
177-func TestKeymapOwnershipAndFocusRepeat(t *testing.T) {
178- adapter, err := NewInputAdapter("")
179- if err != nil {
180- t.Fatal(err)
181- }
182- defer adapter.Close()
183- data, err := os.ReadFile("../../keyboard/testdata/fr.xkb")
184- if err != nil {
185- t.Fatal(err)
186- }
187- for _, tc := range []struct {
188- name string
189- data []byte
190- format uint32
191- discard bool
192- good bool
193- }{
194- {"valid", append(data, 0), 1, false, true},
195- {"invalid", []byte("bad keymap\x00"), 1, false, false},
196- {"unsupported", append(data, 0), 2, false, false},
197- {"disconnect", append(data, 0), 1, true, false},
198- } {
199- t.Run(tc.name, func(t *testing.T) {
200- f, err := os.CreateTemp(t.TempDir(), "keymap")
201- if err != nil {
202- t.Fatal(err)
203- }
204- if _, err = f.Write(tc.data); err != nil {
205- t.Fatal(err)
206- }
207- // ApplyInput owns a duplicate, not the os.File descriptor.
208- fd, err := unix.Dup(int(f.Fd()))
209- if err != nil {
210- t.Fatal(err)
211- }
212- defer f.Close()
213- ev := Event{Kind: InputKeymap, FD: fd, Format: tc.format, Size: uint32(len(tc.data))}
214- if tc.discard {
215- CloseEventFD(ev)
216- } else {
217- _, err = adapter.ApplyInput(ev, time.Now())
218- if (err == nil) != tc.good {
219- t.Fatalf("apply: %v", err)
220- }
221- }
222- if err = unix.Fstat(ev.FD, &unix.Stat_t{}); err != unix.EBADF {
223- t.Fatalf("keymap FD still open: %v", err)
224- }
225- })
226- }
227- now := time.Now()
228- for _, ev := range []Event{{Kind: InputFocusIn, Serial: 42}, {Kind: InputRepeatInfo, Rate: 20, Delay: 100}, {Kind: InputKey, Code: 16, Pressed: true, Serial: 43}} {
229- if _, err := adapter.ApplyInput(ev, now); err != nil {
230- t.Fatal(err)
231- }
232- }
233- if _, ok := adapter.NextRepeat(); !ok {
234- t.Fatal("missing repeat")
235- }
236- if _, err := adapter.ApplyInput(Event{Kind: InputRepeatInfo, Rate: 0}, now); err != nil {
237- t.Fatal(err)
238- }
239- if _, ok := adapter.NextRepeat(); ok {
240- t.Fatal("rate zero retained repeat")
241- }
242- if _, err := adapter.ApplyInput(Event{Kind: InputRepeatInfo, Rate: 20, Delay: 100}, now); err != nil {
243- t.Fatal(err)
244- }
245- if _, err := adapter.ApplyInput(Event{Kind: InputKey, Code: 16, Pressed: true}, now); err != nil {
246- t.Fatal(err)
247- }
248- if _, ok := adapter.NextRepeat(); !ok {
249- t.Fatal("repeat not rearmed")
250- }
251- if _, err := adapter.ApplyInput(Event{Kind: InputFocusOut}, now); err != nil {
252- t.Fatal(err)
253- }
254- if _, ok := adapter.NextRepeat(); ok {
255- t.Fatal("keyboard leave retained repeat")
256- }
257-
258- // Overflow reset cancels repeat from a discarded press but keeps focus.
259- w := &Window{inputAdapter: adapter}
260- for _, ev := range []Event{{Kind: InputFocusIn, Serial: 44}, {Kind: InputKey, Code: 16, Pressed: true, Serial: 45}} {
261- if _, err := adapter.ApplyInput(ev, now); err != nil {
262- t.Fatal(err)
263- }
264- }
265- for i := 0; i <= 4096; i++ {
266- w.queueInput([]Input{{Kind: "press"}})
267- }
268- if _, ok := adapter.NextRepeat(); ok {
269- t.Fatal("overflow reset retained repeat")
270- }
271- out, err := adapter.ApplyInput(Event{Kind: InputKey, Code: 16, Pressed: true, Serial: 46}, now)
272- if err != nil || len(out) != 1 {
273- t.Fatalf("key after reset: %v %+v", err, out)
274- }
275- if _, ok := adapter.NextRepeat(); !ok {
276- t.Fatal("focus lost by overflow reset")
277- }
278-}
279-
280-func TestInputQueueOverflowResetAndCoalescing(t *testing.T) {
281- w := &Window{}
282- for i := 0; i < 4096; i++ {
283- w.queueInput([]Input{{Kind: "press", Button: uint32(i)}})
284- }
285- w.queueInput([]Input{{Kind: "release", Button: 7}})
286- got := w.DrainInput(nil)
287- if len(got) != 2 || got[0].Kind != InputReset || got[1].Kind != "release" || w.InputOverflow != 4096 {
288- t.Fatalf("reset: len=%d events=%+v overflows=%d", len(got), got, w.InputOverflow)
289- }
290- w.queueInput([]Input{{Kind: "motion", X: 1}})
291- for i := 1; i < 4096; i++ {
292- w.queueInput([]Input{{Kind: "press"}})
293- }
294- w.queueInput([]Input{{Kind: "focus-out"}})
295- got = w.DrainInput(nil)
296- if len(got) != 4096 || got[0].Kind != "press" || got[4095].Kind != "focus-out" || w.InputOverflow != 4097 {
297- t.Fatalf("eviction: len=%d first=%+v last=%+v overflows=%d", len(got), got[0], got[len(got)-1], w.InputOverflow)
298- }
299- w.queueInput([]Input{{Kind: "axis", Axis: 1, DY: 2}})
300- w.queueInput([]Input{{Kind: "axis", Axis: 1, DY: 3}})
301- for i := 2; i < 4096; i++ {
302- w.queueInput([]Input{{Kind: "press"}})
303- }
304- w.queueInput([]Input{{Kind: "release"}})
305- got = w.DrainInput(nil)
306- if len(got) != 4096 || got[0].Kind != "axis" || got[0].DY != 5 || got[4095].Kind != "release" || w.InputOverflow != 4097 {
307- t.Fatalf("axis coalescing: len=%d first=%+v last=%+v overflows=%d", len(got), got[0], got[len(got)-1], w.InputOverflow)
308- }
309- // Eviction skips enter motions; a reset keeps the latest one.
310- w.queueInput([]Input{{Kind: "motion", Enter: true, Serial: 5}})
311- for i := 1; i < 4096; i++ {
312- w.queueInput([]Input{{Kind: "press"}})
313- }
314- w.queueInput([]Input{{Kind: "release"}})
315- got = w.DrainInput(nil)
316- if len(got) != 3 || got[0].Kind != InputReset || !got[1].Enter || got[1].Serial != 5 || got[2].Kind != "release" {
317- t.Fatalf("enter across reset: len=%d first=%+v", len(got), got[:min(3, len(got))])
318- }
319-}
320-
321-func TestApplySeatPointerKeyAndDrain(t *testing.T) {
322- w := &Window{}
323- data, err := os.ReadFile("../../keyboard/testdata/fr.xkb")
324- if err != nil {
325- t.Fatal(err)
326- }
327- f, err := os.CreateTemp(t.TempDir(), "keymap")
328- if err != nil {
329- t.Fatal(err)
330- }
331- if _, err = f.Write(append(data, 0)); err != nil {
332- t.Fatal(err)
333- }
334- // ApplyInput consumes the event FD; keep the os.File descriptor separate.
335- fd, err := unix.Dup(int(f.Fd()))
336- if err != nil {
337- t.Fatal(err)
338- }
339- defer f.Close()
340- for _, ev := range []Event{
341- {Kind: InputKeymap, FD: fd, Format: 1, Size: uint32(len(data) + 1)},
342- {Kind: InputFocusIn, Serial: 3},
343- {Kind: SeatCapabilities, Capabilities: 3},
344- {Kind: InputFrame, Inputs: []Input{{Kind: "motion", X: 4}, {Kind: "press", Button: 1}}},
345- {Kind: InputKey, Code: 16, Pressed: true, Serial: 4},
346- } {
347- if err := w.Apply(ev); err != nil {
348- t.Fatal(err)
349- }
350- }
351- defer w.inputAdapter.Close()
352- got := w.DrainInput(nil)
353- if len(got) != 4 || got[0].Kind != "focus-in" || got[1].Kind != "motion" || got[2].Kind != "press" || got[3].Kind != "key" {
354- t.Fatalf("drained: %+v", got)
355- }
356- if len(w.DrainInput(nil)) != 0 {
357- t.Fatal("queue not drained")
358- }
359-}
D internal/platform/wayland/layer.go +0 -431
@@ -1,431 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "errors"
7- "fmt"
8- "math"
9- "slices"
10- "sort"
11- "strings"
12-
13- "github.com/bnema/wlturbo"
14- "github.com/bnema/wlturbo/protocol/core"
15- "github.com/bnema/wlturbo/wl"
16-
17- "github.com/bnema/nefergui/internal/platform/wayland/layershell"
18-)
19-
20-// zwlr_layer_shell_v1 layers. The values are the wire values.
21-const (
22- LayerBackground uint32 = 0
23- LayerBottom uint32 = 1
24- LayerTop uint32 = 2
25- LayerOverlay uint32 = 3
26-)
27-
28-// zwlr_layer_surface_v1 anchor bits. They may be OR-ed together.
29-const (
30- AnchorTop uint32 = 1
31- AnchorBottom uint32 = 2
32- AnchorLeft uint32 = 4
33- AnchorRight uint32 = 8
34-
35- anchorAll = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
36-)
37-
38-// zwlr_layer_surface_v1 keyboard interactivity modes.
39-const (
40- KeyboardNone uint32 = 0
41- KeyboardExclusive uint32 = 1
42- // KeyboardOnDemand needs zwlr_layer_shell_v1 version 4.
43- KeyboardOnDemand uint32 = 2
44-)
45-
46-const (
47- layerShellSupported = 4
48- outputNameVersion = 4 // wl_output.name was added in version 4
49- // MaxLayerDimension bounds a compositor-assigned layer surface extent.
50- MaxLayerDimension = 16384
51- // DefaultLayerNamespace is used when LayerOptions.Namespace is empty. The
52- // namespace is an opaque, generic role hint; nothing privileged depends on it.
53- DefaultLayerNamespace = "nefergui"
54-)
55-
56-// Rect is an integer rectangle in surface-local logical coordinates.
57-type Rect struct{ X, Y, W, H int32 }
58-
59-// LayerOptions selects wlr-layer-shell instead of xdg-toplevel.
60-type LayerOptions struct {
61- // Output is the exact wl_output name (for example "DP-1"). Empty lets the
62- // compositor choose its preferred output. A named output that is absent
63- // fails the connection; a named output removed later closes the window.
64- Output string
65- // 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
69- // Namespace is the layer surface namespace; empty selects DefaultLayerNamespace.
70- Namespace string
71- // Anchor is a bit set of Anchor* values.
72- Anchor uint32
73- // Layer is one of the Layer* values.
74- Layer uint32
75- // Keyboard is one of the Keyboard* values.
76- Keyboard uint32
77- // ExclusiveZone follows zwlr_layer_surface_v1.set_exclusive_zone
78- // (positive reserves, 0 is neutral, -1 ignores other zones).
79- ExclusiveZone int32
80- // Margin is ordered top, right, bottom, left, like the protocol request.
81- Margin [4]int32
82-}
83-
84-// SurfaceOptions configures the role and input region of a new Window. The zero
85-// value selects the default xdg-toplevel with full-surface input.
86-type SurfaceOptions struct {
87- // Layer, when non-nil, creates a layer surface and binds no xdg shell.
88- Layer *LayerOptions
89- // Lock selects a private output lifetime on this Connection's sole lock.
90- // It is incompatible with Layer, transparency and custom input regions.
91- Lock *LockOptions
92- // InputRects is the initial input region in surface-local logical pixels.
93- // nil means the whole surface (the protocol default); a non-nil empty slice
94- // means no input (pointer and touch pass through).
95- InputRects []Rect
96-}
97-
98-// Validate reports the first invalid option.
99-func (o SurfaceOptions) Validate() error {
100- if o.Lock != nil && (o.Layer != nil || o.InputRects != nil) {
101- return errors.New("lock role cannot have layer or custom input")
102- }
103- if o.Layer != nil {
104- if err := o.Layer.Validate(); err != nil {
105- return err
106- }
107- }
108- return validateRects(o.InputRects)
109-}
110-
111-// Validate reports the first invalid layer option.
112-func (o LayerOptions) Validate() error {
113- switch {
114- case o.Layer > LayerOverlay:
115- return fmt.Errorf("invalid layer %d", o.Layer)
116- case o.Anchor&^anchorAll != 0:
117- return fmt.Errorf("invalid anchor bits %#x", o.Anchor)
118- case o.ExclusiveZone < -1:
119- return fmt.Errorf("invalid exclusive zone %d", o.ExclusiveZone)
120- case o.Keyboard > KeyboardOnDemand:
121- return fmt.Errorf("invalid keyboard interactivity %d", o.Keyboard)
122- case strings.ContainsRune(o.Namespace, 0):
123- return errors.New("layer namespace contains NUL")
124- case strings.ContainsRune(o.Output, 0):
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")
128- }
129- return nil
130-}
131-
132-func validateRects(rects []Rect) error {
133- for i, r := range rects {
134- if r.W <= 0 || r.H <= 0 {
135- return fmt.Errorf("input rect %d has non-positive size %dx%d", i, r.W, r.H)
136- }
137- if int64(r.X)+int64(r.W) > math.MaxInt32 || int64(r.Y)+int64(r.H) > math.MaxInt32 {
138- return fmt.Errorf("input rect %d overflows", i)
139- }
140- }
141- return nil
142-}
143-
144-// layerConfigureSize validates a configure extent. Zero leaves that axis unchanged.
145-func layerConfigureSize(width, height uint32) (int32, int32, error) {
146- if width > MaxLayerDimension || height > MaxLayerDimension {
147- return 0, 0, fmt.Errorf("layer configure size %dx%d exceeds %d", width, height, MaxLayerDimension)
148- }
149- return int32(width), int32(height), nil
150-}
151-
152-// SetTitle sets the toplevel title. Layer surfaces have no title, so it is a no-op.
153-func (w *Window) SetTitle(title string) error {
154- if w == nil || w.Toplevel == nil {
155- return nil
156- }
157- return w.Toplevel.SetTitle(title)
158-}
159-
160-// IsLayer reports whether the window is a layer surface.
161-func (w *Window) IsLayer() bool { return w != nil && w.LayerSurface != nil }
162-
163-// StageInputRects validates rects and stages them as the pending input region
164-// without committing; the next commit (normally Present) applies them. nil
165-// restores the full surface and a non-nil empty slice makes the surface pass
166-// all input through. A request identical to the current one sends nothing and
167-// allocates nothing. Call from the session owner loop only.
168-func (w *Window) StageInputRects(rects []Rect) error {
169- _, err := w.stageInputRects(rects)
170- return err
171-}
172-
173-// SetInputRects stages rects like StageInputRects and, only if they changed and
174-// the window is configured, commits immediately. Before the first configure
175-// the initial commit carries the region.
176-func (w *Window) SetInputRects(rects []Rect) error {
177- changed, err := w.stageInputRects(rects)
178- if err != nil || !changed || !w.Configured {
179- return err
180- }
181- return w.Surface.Commit()
182-}
183-
184-func (w *Window) stageInputRects(rects []Rect) (changed bool, err error) {
185- if w == nil || w.Surface == nil || w.Compositor == nil {
186- return false, errors.New("window has no surface")
187- }
188- if err = validateRects(rects); err != nil {
189- return false, err
190- }
191- if (rects != nil) == w.inputCustom && slices.Equal(rects, w.inputRects) {
192- return false, nil
193- }
194- if err = w.stageInputRegion(rects); err != nil {
195- return false, err
196- }
197- w.inputCustom = rects != nil
198- w.inputRects = append(w.inputRects[:0], rects...)
199- return true, nil
200-}
201-
202-func (w *Window) stageInputRegion(rects []Rect) error {
203- if rects == nil {
204- return w.Surface.SetInputRegion(nil)
205- }
206- region, err := w.Compositor.CreateRegion()
207- if err != nil {
208- return err
209- }
210- for _, r := range rects {
211- if err = region.Add(r.X, r.Y, r.W, r.H); err != nil {
212- break
213- }
214- }
215- if err == nil {
216- err = w.Surface.SetInputRegion(region)
217- }
218- _ = region.Destroy()
219- return err
220-}
221-
222-// applyLayerSize applies a validated layer configure; zero axes are unchanged.
223-func (w *Window) applyLayerSize(width, height int32) {
224- if width > 0 && w.Width != width {
225- w.Width, w.Dirty = width, true
226- }
227- if height > 0 && w.Height != height {
228- w.Height, w.Dirty = height, true
229- }
230-}
231-
232-// bindLayerShell binds zwlr_layer_shell_v1 (up to version 4).
233-func (w *Window) bindLayerShell(bind func(string, uint32, uint32, wl.Proxy) error) error {
234- ls := layershell.NewLayerShell(w.Display.Context())
235- if err := bind(layershell.LayerShellInterface, layerShellSupported, 1, ls); err != nil {
236- w.Display.Context().Unregister(ls) // never bound: no destructor to send
237- return err
238- }
239- w.LayerShell = ls
240- return nil
241-}
242-
243-type outputCandidate struct {
244- out *core.Output
245- global uint32
246- name string
247-}
248-
249-// releaseOutputs releases every candidate except keep.
250-func releaseOutputs(cands []*outputCandidate, keep *outputCandidate) {
251- for _, c := range cands {
252- if c != keep {
253- _ = c.out.Release()
254- }
255- }
256-}
257-
258-// selectOutput binds every wl_output that can report a name and returns the
259-// global name of the exact match. Unmatched outputs are released.
260-func (w *Window) selectOutput(name string) (*core.Output, uint32, error) {
261- if w.connection != nil {
262- for _, out := range w.connection.outputs {
263- if out.Name == name {
264- return out.proxy, out.Global, nil
265- }
266- }
267- return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: fmt.Errorf("output %q not found in applied inventory", name)}
268- }
269- reg := w.Display.Registry()
270- var globals []wl.Global
271- legacy := 0 // outputs older than v4 cannot report a name
272- for _, g := range reg.GetGlobals() {
273- if g.Interface != core.OutputInterface {
274- continue
275- }
276- if g.Version >= outputNameVersion {
277- globals = append(globals, g)
278- } else {
279- legacy++
280- }
281- }
282- sort.Slice(globals, func(i, j int) bool { return globals[i].Name < globals[j].Name })
283- cands := make([]*outputCandidate, 0, len(globals))
284- ctx := w.Display.Context()
285- for _, g := range globals {
286- c := &outputCandidate{out: core.NewOutput(ctx), global: g.Name}
287- c.out.OnName(func(n string) { c.name = n })
288- if err := reg.Bind(g.Name, core.OutputInterface, outputNameVersion, c.out); err != nil {
289- ctx.Unregister(c.out)
290- releaseOutputs(cands, nil)
291- return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: err}
292- }
293- cands = append(cands, c)
294- }
295- if len(cands) > 0 {
296- if err := w.Display.Roundtrip(); err != nil {
297- releaseOutputs(cands, nil)
298- return nil, 0, err
299- }
300- }
301- var chosen *outputCandidate
302- for _, c := range cands {
303- if c.name == name {
304- chosen = c
305- break
306- }
307- }
308- releaseOutputs(cands, chosen)
309- if chosen == nil {
310- cause := fmt.Errorf("output %q not found", name)
311- if legacy > 0 {
312- cause = fmt.Errorf("output %q not found; %d wl_output global(s) older than version %d cannot report names", name, legacy, outputNameVersion)
313- }
314- return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: cause}
315- }
316- return chosen.out, chosen.global, nil
317-}
318-
319-// createLayerSurface creates the role object and stages all double-buffered
320-// state. The caller performs the initial empty commit.
321-func (w *Window) createLayerSurface(o LayerOptions) error {
322- if o.Keyboard == KeyboardOnDemand && w.LayerShell.Version() < 4 {
323- return &CapabilityError{Name: layershell.LayerShellInterface, Cause: fmt.Errorf("on-demand keyboard requires version 4, compositor negotiated %d", w.LayerShell.Version())}
324- }
325- var out *core.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 != "" {
336- var global uint32
337- var err error
338- if out, global, err = w.selectOutput(o.Output); err != nil {
339- return err
340- }
341- w.layerOutputGlobal, w.layerOutputSelected = global, true
342- w.Display.Registry().AddGlobalRemoveHandler(outputRemoved{w})
343- }
344- ns := o.Namespace
345- if ns == "" {
346- ns = DefaultLayerNamespace
347- }
348- ls, err := w.LayerShell.GetLayerSurface(w.Surface, out, o.Layer, ns)
349- if out != nil && w.connection == nil {
350- // The compositor keeps its own reference to the output. A shared
351- // connection owns the proxy in its output inventory instead.
352- _ = out.Release()
353- }
354- if err != nil {
355- return err
356- }
357- w.LayerSurface = ls
358- ls.OnClosed(func() { w.postClosed() })
359- ls.OnConfigure(func(serial, width, height uint32) {
360- cw, ch, e := layerConfigureSize(width, height)
361- if e != nil {
362- w.feedbackFailure(e)
363- return
364- }
365- if w.readerStarted.Load() {
366- w.post(Event{Kind: ConfigureLayer, Width: cw, Height: ch, Serial: serial})
367- return
368- }
369- w.applyLayerSize(cw, ch)
370- if e = ls.AckConfigure(serial); e != nil {
371- w.feedbackFailure(e)
372- return
373- }
374- w.Configured, w.FrameReady = true, true
375- })
376- // Opposite anchors let the compositor assign that axis (size 0).
377- reqW, reqH := uint32(w.Width), uint32(w.Height)
378- if o.Anchor&(AnchorLeft|AnchorRight) == AnchorLeft|AnchorRight {
379- reqW = 0
380- }
381- if o.Anchor&(AnchorTop|AnchorBottom) == AnchorTop|AnchorBottom {
382- reqH = 0
383- }
384- if err = ls.SetSize(reqW, reqH); err != nil {
385- return err
386- }
387- if err = ls.SetAnchor(o.Anchor); err != nil {
388- return err
389- }
390- if err = ls.SetExclusiveZone(o.ExclusiveZone); err != nil {
391- return err
392- }
393- if err = ls.SetMargin(o.Margin[0], o.Margin[1], o.Margin[2], o.Margin[3]); err != nil {
394- return err
395- }
396- return ls.SetKeyboardInteractivity(o.Keyboard)
397-}
398-
399-// outputRemoved closes the window when its selected output disappears.
400-type outputRemoved struct{ w *Window }
401-
402-func (h outputRemoved) HandleRegistryGlobalRemove(ev wlturbo.RegistryGlobalRemoveEvent) {
403- if h.w.layerOutputSelected && ev.Name == h.w.layerOutputGlobal {
404- h.w.postClosed()
405- }
406-}
407-
408-func (w *Window) postClosed() {
409- if w.readerStarted.Load() {
410- w.post(Event{Kind: CloseEvent})
411- return
412- }
413- w.Closed = true
414-}
415-
416-// closeLayer destroys the layer role and its factory; the wl_surface goes last.
417-func (w *Window) closeLayer() {
418- if w.LayerSurface != nil {
419- _ = w.LayerSurface.Destroy()
420- w.LayerSurface = nil
421- }
422- if w.LayerShell != nil {
423- // zwlr_layer_shell_v1.destroy needs version 3; otherwise forget it locally.
424- if w.LayerShell.Destroy() != nil {
425- if c := w.LayerShell.Context(); c != nil {
426- c.Unregister(w.LayerShell)
427- }
428- }
429- w.LayerShell = nil
430- }
431-}
D internal/platform/wayland/layer_test.go +0 -631
@@ -1,631 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "context"
7- "encoding/binary"
8- "errors"
9- "fmt"
10- "io"
11- "net"
12- "os"
13- "path/filepath"
14- "strings"
15- "testing"
16- "time"
17-
18- "github.com/bnema/nefergui/internal/harness"
19- "github.com/bnema/wlturbo"
20- "github.com/bnema/wlturbo/protocol/core"
21- "github.com/bnema/wlturbo/wl"
22-)
23-
24-func TestLayerConstantsMatchProtocol(t *testing.T) {
25- if LayerBackground != 0 || LayerBottom != 1 || LayerTop != 2 || LayerOverlay != 3 {
26- t.Fatal("layer values")
27- }
28- if AnchorTop != 1 || AnchorBottom != 2 || AnchorLeft != 4 || AnchorRight != 8 {
29- t.Fatal("anchor bits")
30- }
31- if KeyboardNone != 0 || KeyboardExclusive != 1 || KeyboardOnDemand != 2 {
32- t.Fatal("keyboard modes")
33- }
34-}
35-
36-func TestSurfaceOptionsValidate(t *testing.T) {
37- ok := LayerOptions{Layer: LayerOverlay, Anchor: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom, Keyboard: KeyboardOnDemand}
38- for name, tc := range map[string]struct {
39- opts SurfaceOptions
40- bad bool
41- }{
42- "zero": {},
43- "valid layer": {opts: SurfaceOptions{Layer: &ok}},
44- "empty passthru": {opts: SurfaceOptions{InputRects: []Rect{}}},
45- "layer 4": {opts: SurfaceOptions{Layer: &LayerOptions{Layer: 4}}, bad: true},
46- "anchor 16": {opts: SurfaceOptions{Layer: &LayerOptions{Anchor: 16}}, bad: true},
47- "zone -2": {opts: SurfaceOptions{Layer: &LayerOptions{ExclusiveZone: -2}}, bad: true},
48- "zone -1": {opts: SurfaceOptions{Layer: &LayerOptions{ExclusiveZone: -1}}},
49- "keyboard 3": {opts: SurfaceOptions{Layer: &LayerOptions{Keyboard: 3}}, bad: true},
50- "namespace NUL": {opts: SurfaceOptions{Layer: &LayerOptions{Namespace: "a\x00b"}}, bad: true},
51- "zero-size rect": {opts: SurfaceOptions{InputRects: []Rect{{0, 0, 0, 5}}}, bad: true},
52- "overflow rect": {opts: SurfaceOptions{InputRects: []Rect{{1<<31 - 2, 0, 5, 5}}}, bad: true},
53- "negative origin": {opts: SurfaceOptions{InputRects: []Rect{{-5, -5, 10, 10}}}},
54- } {
55- if err := tc.opts.Validate(); (err != nil) != tc.bad {
56- t.Errorf("%s: err=%v", name, err)
57- }
58- }
59- if _, err := ConnectWithOptions(context.Background(), "", 10, 10, false, SurfaceOptions{Layer: &LayerOptions{Layer: 9}}); err == nil || !strings.Contains(err.Error(), "invalid layer") {
60- t.Fatalf("connect must validate before dialing: %v", err)
61- }
62-}
63-
64-func TestLayerConfigureSizeBounds(t *testing.T) {
65- if w, h, err := layerConfigureSize(0, 16384); err != nil || w != 0 || h != 16384 {
66- t.Fatal(w, h, err)
67- }
68- if _, _, err := layerConfigureSize(16385, 1); err == nil {
69- t.Fatal("oversize accepted")
70- }
71- w := &Window{Width: 100, Height: 50}
72- w.applyLayerSize(0, 40)
73- if w.Width != 100 || w.Height != 40 || !w.Dirty {
74- t.Fatalf("%+v", w)
75- }
76-}
77-
78-// A post-startup configure must carry size and serial together, even when the
79-// previous frame is ready. Applying it must ack before the owner can commit.
80-func TestLayerConfigureAfterReaderStart(t *testing.T) {
81- srv, accept := newWireServer(t)
82- type dialed struct {
83- d *wlturbo.Display
84- err error
85- }
86- ch := make(chan dialed, 1)
87- go func() {
88- d, err := wlturbo.Connect(srv.path)
89- ch <- dialed{d, err}
90- }()
91- conn := accept()
92- r := <-ch
93- if r.err != nil {
94- t.Fatal(r.err)
95- }
96- d := r.d
97- defer d.Close()
98- comp := core.NewCompositor(d.Context())
99- if err := d.Registry().Bind(1, core.CompositorInterface, 6, comp); err != nil {
100- t.Fatal(err)
101- }
102- surface, err := comp.CreateSurface()
103- if err != nil {
104- t.Fatal(err)
105- }
106- w := &Window{Display: d, Compositor: comp, Surface: surface, Width: 100, Height: 40,
107- Configured: true, FrameReady: true, events: make(chan Event, 8), readerStop: make(chan struct{})}
108- if err := w.bindLayerShell(func(iface string, max, min uint32, p wl.Proxy) error {
109- return d.Registry().Bind(2, iface, max, p)
110- }); err != nil {
111- t.Fatal(err)
112- }
113- if err := w.createLayerSurface(LayerOptions{Layer: LayerTop}); err != nil {
114- t.Fatal(err)
115- }
116- w.StartReader()
117- defer func() {
118- close(w.readerStop)
119- _ = d.Close()
120- <-w.readerDone
121- }()
122- for _, tc := range []struct {
123- serial, width, height uint32
124- wantW, wantH int32
125- ready bool
126- }{{7, 200, 50, 200, 50, true}, {8, 0, 60, 200, 60, false}} {
127- w.FrameReady, w.Dirty = tc.ready, false
128- if _, err := conn.Write(frame(w.LayerSurface.ID(), 0, tc.serial, tc.width, tc.height)); err != nil {
129- t.Fatal(err)
130- }
131- var ev Event
132- select {
133- case ev = <-w.Events():
134- case <-time.After(5 * time.Second):
135- t.Fatal("no configure event")
136- }
137- if ev.Kind != ConfigureLayer || ev.Width != int32(tc.width) || ev.Height != int32(tc.height) || ev.Serial != tc.serial {
138- t.Fatalf("configure must be one size+serial event: %+v", ev)
139- }
140- if err := w.Apply(ev); err != nil {
141- t.Fatal(err)
142- }
143- if w.Width != tc.wantW || w.Height != tc.wantH || !w.Dirty || !w.Configured || !w.FrameReady {
144- t.Fatalf("configure state: size=%dx%d dirty=%v configured=%v ready=%v", w.Width, w.Height, w.Dirty, w.Configured, w.FrameReady)
145- }
146- if err := surface.Commit(); err != nil {
147- t.Fatal(err)
148- }
149- // Ignore setup requests, but require exactly one ack of this configure
150- // before the first subsequent surface commit.
151- _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
152- acks := 0
153- for {
154- var hdr [8]byte
155- if _, err := io.ReadFull(conn, hdr[:]); err != nil {
156- t.Fatal(err)
157- }
158- obj, word := binary.NativeEndian.Uint32(hdr[:]), binary.NativeEndian.Uint32(hdr[4:])
159- size, op := int(word>>16), uint16(word)
160- if size < 8 {
161- t.Fatalf("bad frame size %d", size)
162- }
163- args := make([]byte, size-8)
164- if _, err := io.ReadFull(conn, args); err != nil {
165- t.Fatal(err)
166- }
167- if obj == w.LayerSurface.ID() && op == 6 {
168- if len(args) != 4 || binary.NativeEndian.Uint32(args) != tc.serial {
169- t.Fatalf("wrong ack payload %x, want serial %d", args, tc.serial)
170- }
171- acks++
172- }
173- if obj == surface.ID() && op == 6 {
174- if acks != 1 {
175- t.Fatalf("commit preceded by %d configure acks, want 1", acks)
176- }
177- break
178- }
179- }
180- }
181-}
182-
183-// Harness acceptance: a real NeferWL compositor and the real client code.
184-func runLayerHarness(t *testing.T, child string) (string, string) {
185- t.Helper()
186- if os.Getenv("NEFERGUI_HARNESS") != "1" {
187- t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the layer-shell harness")
188- }
189- root := filepath.Join(t.TempDir(), "artifacts")
190- result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Timeout: 30 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^" + child + "$", "-test.v"}})
191- out, _ := os.ReadFile(result.Artifacts["client_stdout"])
192- errOut, _ := os.ReadFile(result.Artifacts["client_stderr"])
193- if code != harness.Pass {
194- t.Fatalf("code=%d status=%s error=%s\nstdout:\n%s\nstderr:\n%s", code, result.Status, result.Error, out, errOut)
195- }
196- return string(out), string(errOut)
197-}
198-
199-func TestHarnessLayerSurface(t *testing.T) {
200- out, _ := runLayerHarness(t, "TestHarnessLayerSurfaceClient")
201- for _, want := range []string{"LAYER configured", "LAYER height=40", "LAYER input-passthrough", "LAYER input-default", "LAYER closed"} {
202- if !strings.Contains(out, want) {
203- t.Fatalf("missing %q in:\n%s", want, out)
204- }
205- }
206-}
207-
208-func TestHarnessLayerOutputSelection(t *testing.T) {
209- out, _ := runLayerHarness(t, "TestHarnessLayerOutputClient")
210- for _, want := range []string{"OUTPUT exact ok", "OUTPUT missing rejected"} {
211- if !strings.Contains(out, want) {
212- t.Fatalf("missing %q in:\n%s", want, out)
213- }
214- }
215-}
216-
217-func TestHarnessXDGDefaultUnchanged(t *testing.T) {
218- out, _ := runLayerHarness(t, "TestHarnessXDGClient")
219- if !strings.Contains(out, "XDG configured toplevel") {
220- t.Fatal(out)
221- }
222-}
223-
224-func harnessChild(t *testing.T) {
225- t.Helper()
226- if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
227- t.Skip("harness child only")
228- }
229-}
230-
231-func TestHarnessLayerSurfaceClient(t *testing.T) {
232- harnessChild(t)
233- w, err := ConnectWithOptions(context.Background(), "", 100, 40, true, SurfaceOptions{
234- InputRects: []Rect{{0, 0, 10, 10}},
235- Layer: &LayerOptions{
236- Layer: LayerTop, Anchor: AnchorTop | AnchorLeft | AnchorRight, Keyboard: KeyboardOnDemand,
237- ExclusiveZone: 40, Margin: [4]int32{2, 3, 4, 5}, Namespace: "nefergui-test",
238- },
239- })
240- if err != nil {
241- t.Fatal(err)
242- }
243- if !w.IsLayer() || w.Toplevel != nil || w.XdgSurface != nil || w.Shell != nil {
244- t.Fatal("layer window must not create xdg objects")
245- }
246- if !w.Configured || w.Width < 100 {
247- t.Fatalf("not configured: %+v", w)
248- }
249- fmt.Println("LAYER configured", w.Width, w.Height)
250- if w.Height == 40 {
251- fmt.Println("LAYER height=40")
252- }
253- if err = w.SetTitle("ignored"); err != nil {
254- t.Fatal(err)
255- }
256- // Empty non-nil region: pass-through; nil: default full surface.
257- if err = w.SetInputRects([]Rect{}); err != nil {
258- t.Fatal(err)
259- }
260- if err = w.SetInputRects([]Rect{}); err != nil {
261- t.Fatal(err)
262- }
263- fmt.Println("LAYER input-passthrough")
264- if err = w.SetInputRects(nil); err != nil {
265- t.Fatal(err)
266- }
267- fmt.Println("LAYER input-default")
268- // A roundtrip proves the compositor processed and accepted every request
269- // (a protocol error would fail it); only then does the reader take over.
270- if err = w.Display.Roundtrip(); err != nil {
271- t.Fatal(err)
272- }
273- // Hand dispatch to the reader and prove it works: a close from the
274- // owner side must still tear down cleanly.
275- events := w.StartReader()
276- for drained := false; !drained; {
277- select {
278- case ev := <-events:
279- if ev.Kind == TransportError || ev.Kind == CloseEvent {
280- t.Fatalf("unexpected %+v", ev)
281- }
282- if err = w.Apply(ev); err != nil {
283- t.Fatal(err)
284- }
285- default:
286- drained = true
287- }
288- }
289- if err = w.Close(); err != nil {
290- t.Fatal(err)
291- }
292- fmt.Println("LAYER closed")
293-}
294-
295-func TestHarnessLayerOutputClient(t *testing.T) {
296- harnessChild(t)
297- d, err := wl.Connect("")
298- if err != nil {
299- t.Fatal(err)
300- }
301- if err = d.Roundtrip(); err != nil {
302- t.Fatal(err)
303- }
304- var name string
305- for _, g := range d.Registry().GetGlobals() {
306- if g.Interface != core.OutputInterface {
307- continue
308- }
309- if g.Version < outputNameVersion {
310- t.Fatalf("wl_output v%d lacks names", g.Version)
311- }
312- o := core.NewOutput(d.Context())
313- o.OnName(func(n string) { name = n })
314- if err = d.Registry().Bind(g.Name, core.OutputInterface, outputNameVersion, o); err != nil {
315- t.Fatal(err)
316- }
317- break
318- }
319- if err = d.Roundtrip(); err != nil {
320- t.Fatal(err)
321- }
322- _ = d.Close()
323- if name == "" {
324- t.Fatal("no output name")
325- }
326- w, err := ConnectWithOptions(context.Background(), "", 100, 40, false, SurfaceOptions{Layer: &LayerOptions{Output: name, Layer: LayerBottom, Anchor: AnchorBottom | AnchorLeft | AnchorRight}})
327- if err != nil {
328- t.Fatalf("output %q: %v", name, err)
329- }
330- if err = w.Close(); err != nil {
331- t.Fatal(err)
332- }
333- fmt.Println("OUTPUT exact ok", name)
334- _, err = ConnectWithOptions(context.Background(), "", 100, 40, false, SurfaceOptions{Layer: &LayerOptions{Output: name + "-missing"}})
335- var ce *CapabilityError
336- if !errors.As(err, &ce) {
337- t.Fatalf("missing output error: %v", err)
338- }
339- fmt.Println("OUTPUT missing rejected")
340-}
341-
342-func TestHarnessXDGClient(t *testing.T) {
343- harnessChild(t)
344- w, err := Connect("", 100, 50, false)
345- if err != nil {
346- t.Fatal(err)
347- }
348- if w.IsLayer() || w.Toplevel == nil || w.XdgSurface == nil || !w.Configured {
349- t.Fatal("default must be xdg toplevel")
350- }
351- if err = w.SetTitle("x"); err != nil {
352- t.Fatal(err)
353- }
354- fmt.Println("XDG configured toplevel")
355- if err = w.Close(); err != nil {
356- t.Fatal(err)
357- }
358-}
359-
360-// wireServer is a real unix-socket Wayland peer; tests speak raw frames.
361-type wireServer struct {
362- conn net.Conn
363- path string
364-}
365-
366-func newWireServer(t *testing.T) (*wireServer, func() net.Conn) {
367- t.Helper()
368- dir := t.TempDir()
369- path := filepath.Join(dir, "wl-test")
370- ln, err := net.Listen("unix", path)
371- if err != nil {
372- t.Fatal(err)
373- }
374- t.Cleanup(func() { _ = ln.Close() })
375- srv := &wireServer{path: path}
376- accepted := make(chan net.Conn, 1)
377- go func() {
378- c, e := ln.Accept()
379- if e == nil {
380- accepted <- c
381- }
382- }()
383- return srv, func() net.Conn {
384- select {
385- case c := <-accepted:
386- srv.conn = c
387- t.Cleanup(func() { _ = c.Close() })
388- return c
389- case <-time.After(5 * time.Second):
390- t.Fatal("client never connected")
391- return nil
392- }
393- }
394-}
395-
396-func frame(object uint32, opcode uint16, args ...uint32) []byte {
397- b := make([]byte, 8+4*len(args))
398- binary.NativeEndian.PutUint32(b, object)
399- binary.NativeEndian.PutUint32(b[4:], uint32(len(b))<<16|uint32(opcode))
400- for i, a := range args {
401- binary.NativeEndian.PutUint32(b[8+4*i:], a)
402- }
403- return b
404-}
405-
406-// A compositor that never answers must not block startup past ctx; the
407-// connection is closed so the server sees EOF.
408-func TestConnectWithOptionsCancelsHangingServer(t *testing.T) {
409- srv, accept := newWireServer(t)
410- ctx, cancel := context.WithCancel(context.Background())
411- type result struct {
412- w *Window
413- err error
414- }
415- done := make(chan result, 1)
416- go func() {
417- w, err := ConnectWithOptions(ctx, srv.path, 100, 40, false, SurfaceOptions{})
418- done <- result{w, err}
419- }()
420- conn := accept()
421- buf := make([]byte, 64)
422- if _, err := conn.Read(buf); err != nil { // get_registry / sync requests: client is now blocked
423- t.Fatal(err)
424- }
425- select {
426- case <-done:
427- t.Fatal("returned before cancel")
428- case <-time.After(100 * time.Millisecond):
429- }
430- cancel()
431- select {
432- case r := <-done:
433- if r.w != nil || !errors.Is(r.err, context.Canceled) {
434- t.Fatalf("window=%v err=%v", r.w, r.err)
435- }
436- case <-time.After(5 * time.Second):
437- t.Fatal("ConnectWithOptions still blocked after cancel")
438- }
439- _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
440- for {
441- if _, err := conn.Read(buf); err != nil {
442- if !errors.Is(err, io.EOF) {
443- t.Fatalf("server expected EOF (client closed), got %v", err)
444- }
445- break
446- }
447- }
448-}
449-
450-func TestConnectWithOptionsPrecancelledAndNil(t *testing.T) {
451- ctx, cancel := context.WithCancel(context.Background())
452- cancel()
453- if _, err := ConnectWithOptions(ctx, "/nonexistent", 1, 1, false, SurfaceOptions{}); !errors.Is(err, context.Canceled) {
454- t.Fatal(err)
455- }
456- //lint:ignore SA1012 nil context is an explicit error case
457- if _, err := ConnectWithOptions(nil, "/nonexistent", 1, 1, false, SurfaceOptions{}); err == nil {
458- t.Fatal("nil context accepted")
459- }
460-}
461-
462-// Removal of the selected output's registry global closes the window, before
463-// and after the reader starts; other globals are ignored.
464-func TestSelectedOutputRemovalClosesWindow(t *testing.T) {
465- for _, reader := range []bool{false, true} {
466- t.Run(fmt.Sprintf("reader=%v", reader), func(t *testing.T) {
467- srv, accept := newWireServer(t)
468- type dialed struct {
469- d *wlturbo.Display
470- err error
471- }
472- ch := make(chan dialed, 1)
473- go func() {
474- d, err := wlturbo.Connect(srv.path)
475- ch <- dialed{d, err}
476- }()
477- conn := accept()
478- r := <-ch
479- if r.err != nil {
480- t.Fatal(r.err)
481- }
482- d := r.d
483- defer d.Close()
484- w := &Window{Display: d, events: make(chan Event, 8), readerStop: make(chan struct{}), layerOutputSelected: true, layerOutputGlobal: 7}
485- d.Registry().AddGlobalRemoveHandler(outputRemoved{w})
486- if reader {
487- w.StartReader()
488- }
489- regID := d.Registry().ID()
490- if _, err := conn.Write(frame(regID, 1, 9)); err != nil { // other global
491- t.Fatal(err)
492- }
493- if !reader {
494- if err := d.Dispatch(); err != nil {
495- t.Fatal(err)
496- }
497- if w.Closed {
498- t.Fatal("unrelated removal closed the window")
499- }
500- } else {
501- select {
502- case ev := <-w.Events():
503- t.Fatalf("unrelated removal posted %+v", ev)
504- case <-time.After(100 * time.Millisecond):
505- }
506- }
507- if _, err := conn.Write(frame(regID, 1, 7)); err != nil {
508- t.Fatal(err)
509- }
510- if !reader {
511- if err := d.Dispatch(); err != nil {
512- t.Fatal(err)
513- }
514- if !w.Closed {
515- t.Fatal("selected output removal did not close")
516- }
517- return
518- }
519- select {
520- case ev := <-w.Events():
521- if ev.Kind != CloseEvent {
522- t.Fatalf("event %+v", ev)
523- }
524- case <-time.After(5 * time.Second):
525- t.Fatal("no CloseEvent")
526- }
527- close(w.readerStop)
528- _ = d.Close()
529- <-w.readerDone
530- })
531- }
532-}
533-
534-// surfaceRequestCounts reads raw client frames until the sentinel (wl_surface.damage on
535-// surface) and returns "object/opcode" counts for the surface.
536-func surfaceRequestCounts(t *testing.T, conn net.Conn, surface uint32) map[uint16]int {
537- t.Helper()
538- counts := map[uint16]int{}
539- _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
540- hdr := make([]byte, 8)
541- for {
542- if _, err := io.ReadFull(conn, hdr); err != nil {
543- t.Fatal(err)
544- }
545- obj := binary.NativeEndian.Uint32(hdr)
546- word := binary.NativeEndian.Uint32(hdr[4:])
547- size, op := int(word>>16), uint16(word)
548- if size < 8 {
549- t.Fatalf("bad frame size %d", size)
550- }
551- if _, err := io.CopyN(io.Discard, conn, int64(size-8)); err != nil {
552- t.Fatal(err)
553- }
554- if obj != surface {
555- continue
556- }
557- if op == 2 { // wl_surface.damage sentinel
558- return counts
559- }
560- counts[op]++
561- }
562-}
563-
564-// StageInputRects never commits; SetInputRects commits only a real change on a
565-// configured window; an unchanged request sends nothing. Counted on the wire.
566-func TestInputRectRequestsOnWire(t *testing.T) {
567- const opSetInputRegion, opCommit = 5, 6
568- srv, accept := newWireServer(t)
569- type dialed struct {
570- d *wlturbo.Display
571- err error
572- }
573- ch := make(chan dialed, 1)
574- go func() {
575- d, err := wlturbo.Connect(srv.path)
576- ch <- dialed{d, err}
577- }()
578- conn := accept()
579- r := <-ch
580- if r.err != nil {
581- t.Fatal(r.err)
582- }
583- d := r.d
584- defer d.Close()
585- comp := core.NewCompositor(d.Context())
586- if err := d.Registry().Bind(1, core.CompositorInterface, 6, comp); err != nil {
587- t.Fatal(err)
588- }
589- surface, err := comp.CreateSurface()
590- if err != nil {
591- t.Fatal(err)
592- }
593- w := &Window{Display: d, Compositor: comp, Surface: surface}
594- step := func(name string, run func() error, wantSet, wantCommit int) {
595- t.Helper()
596- if err := run(); err != nil {
597- t.Fatal(name, err)
598- }
599- if err := surface.Damage(0, 0, 1, 1); err != nil {
600- t.Fatal(err)
601- }
602- got := surfaceRequestCounts(t, conn, surface.ID())
603- if got[opSetInputRegion] != wantSet || got[opCommit] != wantCommit {
604- t.Fatalf("%s: set_input_region=%d commit=%d, want %d/%d", name, got[opSetInputRegion], got[opCommit], wantSet, wantCommit)
605- }
606- }
607- rects := []Rect{{0, 0, 10, 10}, {20, 0, 5, 5}}
608- step("stage", func() error { return w.StageInputRects(rects) }, 1, 0)
609- step("stage identical", func() error { return w.StageInputRects(append([]Rect(nil), rects...)) }, 0, 0)
610- step("set identical while configured", func() error { w.Configured = true; return w.SetInputRects(rects) }, 0, 0)
611- step("set changed", func() error { return w.SetInputRects([]Rect{{1, 1, 2, 2}}) }, 1, 1)
612- step("set passthrough", func() error { return w.SetInputRects([]Rect{}) }, 1, 1)
613- step("set passthrough again", func() error { return w.SetInputRects([]Rect{}) }, 0, 0)
614- step("set default", func() error { return w.SetInputRects(nil) }, 1, 1)
615- step("set default again", func() error { return w.SetInputRects(nil) }, 0, 0)
616- w.Configured = false
617- step("unconfigured change stages only", func() error { return w.SetInputRects(rects) }, 1, 0)
618- before := len(w.inputRects)
619- step("invalid stays unsent", func() error {
620- if err := w.StageInputRects([]Rect{{0, 0, 0, 1}}); err == nil {
621- t.Fatal("invalid rect accepted")
622- }
623- return nil
624- }, 0, 0)
625- if len(w.inputRects) != before {
626- t.Fatal("invalid request changed cached state")
627- }
628- if n := testing.AllocsPerRun(100, func() { _ = w.StageInputRects(rects) }); n != 0 {
629- t.Fatalf("unchanged stage allocates %v", n)
630- }
631-}
D internal/platform/wayland/layershell/generate.go +0 -8
@@ -1,8 +0,0 @@
1-//go:build linux
2-
3-// Package layershell contains local typed bindings for wlr-layer-shell v1.
4-// wlturbo v0.3.0 has no generated layer-shell package, so the bindings are
5-// generated here by its scanner from the vendored protocol XML.
6-package layershell
7-
8-//go:generate go run github.com/bnema/wlturbo/cmd/wlturbo-scanner -p layershell -o wlr-layer-shell-unstable-v1_generated.go -import wl_surface=github.com/bnema/wlturbo/protocol/core -import wl_output=github.com/bnema/wlturbo/protocol/core -import xdg_popup=github.com/bnema/wlturbo/protocol/xdgshell wlr-layer-shell-unstable-v1.xml
D internal/platform/wayland/layershell/wlr-layer-shell-unstable-v1.xml +0 -407
@@ -1,407 +0,0 @@
1-<?xml version="1.0" encoding="UTF-8"?>
2-<protocol name="wlr_layer_shell_unstable_v1">
3- <copyright>
4- Copyright © 2017 Drew DeVault
5-
6- Permission to use, copy, modify, distribute, and sell this
7- software and its documentation for any purpose is hereby granted
8- without fee, provided that the above copyright notice appear in
9- all copies and that both that copyright notice and this permission
10- notice appear in supporting documentation, and that the name of
11- the copyright holders not be used in advertising or publicity
12- pertaining to distribution of the software without specific,
13- written prior permission. The copyright holders make no
14- representations about the suitability of this software for any
15- purpose. It is provided "as is" without express or implied
16- warranty.
17-
18- THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
19- SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
20- FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
21- SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
22- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
23- AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
24- ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
25- THIS SOFTWARE.
26- </copyright>
27-
28- <interface name="zwlr_layer_shell_v1" version="5">
29- <description summary="create surfaces that are layers of the desktop">
30- Clients can use this interface to assign the surface_layer role to
31- wl_surfaces. Such surfaces are assigned to a "layer" of the output and
32- rendered with a defined z-depth respective to each other. They may also be
33- anchored to the edges and corners of a screen and specify input handling
34- semantics. This interface should be suitable for the implementation of
35- many desktop shell components, and a broad number of other applications
36- that interact with the desktop.
37- </description>
38-
39- <request name="get_layer_surface">
40- <description summary="create a layer_surface from a surface">
41- Create a layer surface for an existing surface. This assigns the role of
42- layer_surface, or raises a protocol error if another role is already
43- assigned.
44-
45- Creating a layer surface from a wl_surface which has a buffer attached
46- or committed is a client error, and any attempts by a client to attach
47- or manipulate a buffer prior to the first layer_surface.configure call
48- must also be treated as errors.
49-
50- After creating a layer_surface object and setting it up, the client
51- must perform an initial commit without any buffer attached.
52- The compositor will reply with a layer_surface.configure event.
53- The client must acknowledge it and is then allowed to attach a buffer
54- to map the surface.
55-
56- You may pass NULL for output to allow the compositor to decide which
57- output to use. Generally this will be the one that the user most
58- recently interacted with.
59-
60- Clients can specify a namespace that defines the purpose of the layer
61- surface.
62- </description>
63- <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
64- <arg name="surface" type="object" interface="wl_surface"/>
65- <arg name="output" type="object" interface="wl_output" allow-null="true"/>
66- <arg name="layer" type="uint" enum="layer" summary="layer to add this surface to"/>
67- <arg name="namespace" type="string" summary="namespace for the layer surface"/>
68- </request>
69-
70- <enum name="error">
71- <entry name="role" value="0" summary="wl_surface has another role"/>
72- <entry name="invalid_layer" value="1" summary="layer value is invalid"/>
73- <entry name="already_constructed" value="2" summary="wl_surface has a buffer attached or committed"/>
74- </enum>
75-
76- <enum name="layer">
77- <description summary="available layers for surfaces">
78- These values indicate which layers a surface can be rendered in. They
79- are ordered by z depth, bottom-most first. Traditional shell surfaces
80- will typically be rendered between the bottom and top layers.
81- Fullscreen shell surfaces are typically rendered at the top layer.
82- Multiple surfaces can share a single layer, and ordering within a
83- single layer is undefined.
84- </description>
85-
86- <entry name="background" value="0"/>
87- <entry name="bottom" value="1"/>
88- <entry name="top" value="2"/>
89- <entry name="overlay" value="3"/>
90- </enum>
91-
92- <!-- Version 3 additions -->
93-
94- <request name="destroy" type="destructor" since="3">
95- <description summary="destroy the layer_shell object">
96- This request indicates that the client will not use the layer_shell
97- object any more. Objects that have been created through this instance
98- are not affected.
99- </description>
100- </request>
101- </interface>
102-
103- <interface name="zwlr_layer_surface_v1" version="5">
104- <description summary="layer metadata interface">
105- An interface that may be implemented by a wl_surface, for surfaces that
106- are designed to be rendered as a layer of a stacked desktop-like
107- environment.
108-
109- Layer surface state (layer, size, anchor, exclusive zone,
110- margin, interactivity) is double-buffered, and will be applied at the
111- time wl_surface.commit of the corresponding wl_surface is called.
112-
113- Attaching a null buffer to a layer surface unmaps it.
114-
115- Unmapping a layer_surface means that the surface cannot be shown by the
116- compositor until it is explicitly mapped again. The layer_surface
117- returns to the state it had right after layer_shell.get_layer_surface.
118- The client can re-map the surface by performing a commit without any
119- buffer attached, waiting for a configure event and handling it as usual.
120- </description>
121-
122- <request name="set_size">
123- <description summary="sets the size of the surface">
124- Sets the size of the surface in surface-local coordinates. The
125- compositor will display the surface centered with respect to its
126- anchors.
127-
128- If you pass 0 for either value, the compositor will assign it and
129- inform you of the assignment in the configure event. You must set your
130- anchor to opposite edges in the dimensions you omit; not doing so is a
131- protocol error. Both values are 0 by default.
132-
133- Size is double-buffered, see wl_surface.commit.
134- </description>
135- <arg name="width" type="uint"/>
136- <arg name="height" type="uint"/>
137- </request>
138-
139- <request name="set_anchor">
140- <description summary="configures the anchor point of the surface">
141- Requests that the compositor anchor the surface to the specified edges
142- and corners. If two orthogonal edges are specified (e.g. 'top' and
143- 'left'), then the anchor point will be the intersection of the edges
144- (e.g. the top left corner of the output); otherwise the anchor point
145- will be centered on that edge, or in the center if none is specified.
146-
147- Anchor is double-buffered, see wl_surface.commit.
148- </description>
149- <arg name="anchor" type="uint" enum="anchor"/>
150- </request>
151-
152- <request name="set_exclusive_zone">
153- <description summary="configures the exclusive geometry of this surface">
154- Requests that the compositor avoids occluding an area with other
155- surfaces. The compositor's use of this information is
156- implementation-dependent - do not assume that this region will not
157- actually be occluded.
158-
159- A positive value is only meaningful if the surface is anchored to one
160- edge or an edge and both perpendicular edges. If the surface is not
161- anchored, anchored to only two perpendicular edges (a corner), anchored
162- to only two parallel edges or anchored to all edges, a positive value
163- will be treated the same as zero.
164-
165- A positive zone is the distance from the edge in surface-local
166- coordinates to consider exclusive.
167-
168- Surfaces that do not wish to have an exclusive zone may instead specify
169- how they should interact with surfaces that do. If set to zero, the
170- surface indicates that it would like to be moved to avoid occluding
171- surfaces with a positive exclusive zone. If set to -1, the surface
172- indicates that it would not like to be moved to accommodate for other
173- surfaces, and the compositor should extend it all the way to the edges
174- it is anchored to.
175-
176- For example, a panel might set its exclusive zone to 10, so that
177- maximized shell surfaces are not shown on top of it. A notification
178- might set its exclusive zone to 0, so that it is moved to avoid
179- occluding the panel, but shell surfaces are shown underneath it. A
180- wallpaper or lock screen might set their exclusive zone to -1, so that
181- they stretch below or over the panel.
182-
183- The default value is 0.
184-
185- Exclusive zone is double-buffered, see wl_surface.commit.
186- </description>
187- <arg name="zone" type="int"/>
188- </request>
189-
190- <request name="set_margin">
191- <description summary="sets a margin from the anchor point">
192- Requests that the surface be placed some distance away from the anchor
193- point on the output, in surface-local coordinates. Setting this value
194- for edges you are not anchored to has no effect.
195-
196- The exclusive zone includes the margin.
197-
198- Margin is double-buffered, see wl_surface.commit.
199- </description>
200- <arg name="top" type="int"/>
201- <arg name="right" type="int"/>
202- <arg name="bottom" type="int"/>
203- <arg name="left" type="int"/>
204- </request>
205-
206- <enum name="keyboard_interactivity">
207- <description summary="types of keyboard interaction possible for a layer shell surface">
208- Types of keyboard interaction possible for layer shell surfaces. The
209- rationale for this is twofold: (1) some applications are not interested
210- in keyboard events and not allowing them to be focused can improve the
211- desktop experience; (2) some applications will want to take exclusive
212- keyboard focus.
213- </description>
214-
215- <entry name="none" value="0">
216- <description summary="no keyboard focus is possible">
217- This value indicates that this surface is not interested in keyboard
218- events and the compositor should never assign it the keyboard focus.
219-
220- This is the default value, set for newly created layer shell surfaces.
221-
222- This is useful for e.g. desktop widgets that display information or
223- only have interaction with non-keyboard input devices.
224- </description>
225- </entry>
226- <entry name="exclusive" value="1">
227- <description summary="request exclusive keyboard focus">
228- Request exclusive keyboard focus if this surface is above the shell surface layer.
229-
230- For the top and overlay layers, the seat will always give
231- exclusive keyboard focus to the top-most layer which has keyboard
232- interactivity set to exclusive. If this layer contains multiple
233- surfaces with keyboard interactivity set to exclusive, the compositor
234- determines the one receiving keyboard events in an implementation-
235- defined manner. In this case, no guarantee is made when this surface
236- will receive keyboard focus (if ever).
237-
238- For the bottom and background layers, the compositor is allowed to use
239- normal focus semantics.
240-
241- This setting is mainly intended for applications that need to ensure
242- they receive all keyboard events, such as a lock screen or a password
243- prompt.
244- </description>
245- </entry>
246- <entry name="on_demand" value="2" since="4">
247- <description summary="request regular keyboard focus semantics">
248- This requests the compositor to allow this surface to be focused and
249- unfocused by the user in an implementation-defined manner. The user
250- should be able to unfocus this surface even regardless of the layer
251- it is on.
252-
253- Typically, the compositor will want to use its normal mechanism to
254- manage keyboard focus between layer shell surfaces with this setting
255- and regular toplevels on the desktop layer (e.g. click to focus).
256- Nevertheless, it is possible for a compositor to require a special
257- interaction to focus or unfocus layer shell surfaces (e.g. requiring
258- a click even if focus follows the mouse normally, or providing a
259- keybinding to switch focus between layers).
260-
261- This setting is mainly intended for desktop shell components (e.g.
262- panels) that allow keyboard interaction. Using this option can allow
263- implementing a desktop shell that can be fully usable without the
264- mouse.
265- </description>
266- </entry>
267- </enum>
268-
269- <request name="set_keyboard_interactivity">
270- <description summary="requests keyboard events">
271- Set how keyboard events are delivered to this surface. By default,
272- layer shell surfaces do not receive keyboard events; this request can
273- be used to change this.
274-
275- This setting is inherited by child surfaces set by the get_popup
276- request.
277-
278- Layer surfaces receive pointer, touch, and tablet events normally. If
279- you do not want to receive them, set the input region on your surface
280- to an empty region.
281-
282- Keyboard interactivity is double-buffered, see wl_surface.commit.
283- </description>
284- <arg name="keyboard_interactivity" type="uint" enum="keyboard_interactivity"/>
285- </request>
286-
287- <request name="get_popup">
288- <description summary="assign this layer_surface as an xdg_popup parent">
289- This assigns an xdg_popup's parent to this layer_surface. This popup
290- should have been created via xdg_surface::get_popup with the parent set
291- to NULL, and this request must be invoked before committing the popup's
292- initial state.
293-
294- See the documentation of xdg_popup for more details about what an
295- xdg_popup is and how it is used.
296- </description>
297- <arg name="popup" type="object" interface="xdg_popup"/>
298- </request>
299-
300- <request name="ack_configure">
301- <description summary="ack a configure event">
302- When a configure event is received, if a client commits the
303- surface in response to the configure event, then the client
304- must make an ack_configure request sometime before the commit
305- request, passing along the serial of the configure event.
306-
307- If the client receives multiple configure events before it
308- can respond to one, it only has to ack the last configure event.
309-
310- A client is not required to commit immediately after sending
311- an ack_configure request - it may even ack_configure several times
312- before its next surface commit.
313-
314- A client may send multiple ack_configure requests before committing, but
315- only the last request sent before a commit indicates which configure
316- event the client really is responding to.
317- </description>
318- <arg name="serial" type="uint" summary="the serial from the configure event"/>
319- </request>
320-
321- <request name="destroy" type="destructor">
322- <description summary="destroy the layer_surface">
323- This request destroys the layer surface.
324- </description>
325- </request>
326-
327- <event name="configure">
328- <description summary="suggest a surface change">
329- The configure event asks the client to resize its surface.
330-
331- Clients should arrange their surface for the new states, and then send
332- an ack_configure request with the serial sent in this configure event at
333- some point before committing the new surface.
334-
335- The client is free to dismiss all but the last configure event it
336- received.
337-
338- The width and height arguments specify the size of the window in
339- surface-local coordinates.
340-
341- The size is a hint, in the sense that the client is free to ignore it if
342- it doesn't resize, pick a smaller size (to satisfy aspect ratio or
343- resize in steps of NxM pixels). If the client picks a smaller size and
344- is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the
345- surface will be centered on this axis.
346-
347- If the width or height arguments are zero, it means the client should
348- decide its own window dimension.
349- </description>
350- <arg name="serial" type="uint"/>
351- <arg name="width" type="uint"/>
352- <arg name="height" type="uint"/>
353- </event>
354-
355- <event name="closed">
356- <description summary="surface should be closed">
357- The closed event is sent by the compositor when the surface will no
358- longer be shown. The output may have been destroyed or the user may
359- have asked for it to be removed. Further changes to the surface will be
360- ignored. The client should destroy the resource after receiving this
361- event, and create a new surface if they so choose.
362- </description>
363- </event>
364-
365- <enum name="error">
366- <entry name="invalid_surface_state" value="0" summary="provided surface state is invalid"/>
367- <entry name="invalid_size" value="1" summary="size is invalid"/>
368- <entry name="invalid_anchor" value="2" summary="anchor bitfield is invalid"/>
369- <entry name="invalid_keyboard_interactivity" value="3" summary="keyboard interactivity is invalid"/>
370- <entry name="invalid_exclusive_edge" value="4" summary="exclusive edge is invalid given the surface anchors"/>
371- </enum>
372-
373- <enum name="anchor" bitfield="true">
374- <entry name="top" value="1" summary="the top edge of the anchor rectangle"/>
375- <entry name="bottom" value="2" summary="the bottom edge of the anchor rectangle"/>
376- <entry name="left" value="4" summary="the left edge of the anchor rectangle"/>
377- <entry name="right" value="8" summary="the right edge of the anchor rectangle"/>
378- </enum>
379-
380- <!-- Version 2 additions -->
381-
382- <request name="set_layer" since="2">
383- <description summary="change the layer of the surface">
384- Change the layer that the surface is rendered on.
385-
386- Layer is double-buffered, see wl_surface.commit.
387- </description>
388- <arg name="layer" type="uint" enum="zwlr_layer_shell_v1.layer" summary="layer to move this surface to"/>
389- </request>
390-
391- <!-- Version 5 additions -->
392-
393- <request name="set_exclusive_edge" since="5">
394- <description summary="set the edge the exclusive zone will be applied to">
395- Requests an edge for the exclusive zone to apply. The exclusive
396- edge will be automatically deduced from anchor points when possible,
397- but when the surface is anchored to a corner, it will be necessary
398- to set it explicitly to disambiguate, as it is not possible to deduce
399- which one of the two corner edges should be used.
400-
401- The edge must be one the surface is anchored to, otherwise the
402- invalid_exclusive_edge protocol error will be raised.
403- </description>
404- <arg name="edge" type="uint" enum="anchor"/>
405- </request>
406- </interface>
407-</protocol>
D internal/platform/wayland/layershell/wlr-layer-shell-unstable-v1_generated.go +0 -311
@@ -1,311 +0,0 @@
1-// Code generated by wlturbo-scanner. DO NOT EDIT.
2-// Protocol: wlr_layer_shell_unstable_v1
3-// Source: wlr-layer-shell-unstable-v1.xml
4-
5-package layershell
6-
7-import (
8- "sync"
9-
10- cross_wl_output "github.com/bnema/wlturbo/protocol/core"
11- cross_wl_surface "github.com/bnema/wlturbo/protocol/core"
12- cross_xdg_popup "github.com/bnema/wlturbo/protocol/xdgshell"
13- "github.com/bnema/wlturbo/wl"
14-)
15-
16-// ERROR_ROLE is the error role value.
17-const ERROR_ROLE = 0
18-
19-// ERROR_INVALID_LAYER is the error invalid_layer value.
20-const ERROR_INVALID_LAYER = 1
21-
22-// ERROR_ALREADY_CONSTRUCTED is the error already_constructed value.
23-const ERROR_ALREADY_CONSTRUCTED = 2
24-
25-// LAYER_BACKGROUND is the layer background value.
26-const LAYER_BACKGROUND = 0
27-
28-// LAYER_BOTTOM is the layer bottom value.
29-const LAYER_BOTTOM = 1
30-
31-// LAYER_TOP is the layer top value.
32-const LAYER_TOP = 2
33-
34-// LAYER_OVERLAY is the layer overlay value.
35-const LAYER_OVERLAY = 3
36-
37-// KEYBOARD_INTERACTIVITY_NONE is the keyboard_interactivity none value.
38-const KEYBOARD_INTERACTIVITY_NONE = 0
39-
40-// KEYBOARD_INTERACTIVITY_EXCLUSIVE is the keyboard_interactivity exclusive value.
41-const KEYBOARD_INTERACTIVITY_EXCLUSIVE = 1
42-
43-// KEYBOARD_INTERACTIVITY_ON_DEMAND is the keyboard_interactivity on_demand value.
44-const KEYBOARD_INTERACTIVITY_ON_DEMAND = 2
45-
46-// ERROR_INVALID_SURFACE_STATE is the error invalid_surface_state value.
47-const ERROR_INVALID_SURFACE_STATE = 0
48-
49-// ERROR_INVALID_SIZE is the error invalid_size value.
50-const ERROR_INVALID_SIZE = 1
51-
52-// ERROR_INVALID_ANCHOR is the error invalid_anchor value.
53-const ERROR_INVALID_ANCHOR = 2
54-
55-// ERROR_INVALID_KEYBOARD_INTERACTIVITY is the error invalid_keyboard_interactivity value.
56-const ERROR_INVALID_KEYBOARD_INTERACTIVITY = 3
57-
58-// ERROR_INVALID_EXCLUSIVE_EDGE is the error invalid_exclusive_edge value.
59-const ERROR_INVALID_EXCLUSIVE_EDGE = 4
60-
61-// ANCHOR_TOP is the anchor top value.
62-const ANCHOR_TOP = 1
63-
64-// ANCHOR_BOTTOM is the anchor bottom value.
65-const ANCHOR_BOTTOM = 2
66-
67-// ANCHOR_LEFT is the anchor left value.
68-const ANCHOR_LEFT = 4
69-
70-// ANCHOR_RIGHT is the anchor right value.
71-const ANCHOR_RIGHT = 8
72-
73-// LayerShellInterface is the protocol interface name.
74-const LayerShellInterface = "zwlr_layer_shell_v1"
75-
76-// LayerShell is the generated binding for zwlr_layer_shell_v1 version 5.
77-type LayerShell struct {
78- wl.BaseProxy
79-}
80-
81-// NewLayerShell returns an unbound LayerShell.
82-func NewLayerShell(ctx *wl.Context) *LayerShell {
83- obj := &LayerShell{}
84- obj.SetContext(ctx)
85- ctx.Register(obj)
86- return obj
87-}
88-
89-// Create a layer surface for an existing surface. This assigns the role of layer_surface, or raises a protocol error if another role is already assigned. Creating a layer surface from a wl_surface which has a buffer attached or committed is a client error, and any attempts by a client to attach or manipulate a buffer prior to the first layer_surface.configure call must also be treated as errors. After creating a layer_surface object and setting it up, the client must perform an initial commit without any buffer attached. The compositor will reply with a layer_surface.configure event. The client must acknowledge it and is then allowed to attach a buffer to map the surface. You may pass NULL for output to allow the compositor to decide which output to use. Generally this will be the one that the user most recently interacted with. Clients can specify a namespace that defines the purpose of the layer surface.
90-func (o *LayerShell) GetLayerSurface(surface *cross_wl_surface.Surface, output *cross_wl_output.Output, layer uint32, namespace string) (*LayerSurface, error) {
91- var arg1 wl.Object
92- if surface != nil {
93- arg1 = surface
94- }
95- var arg2 wl.Object
96- if output != nil {
97- arg2 = output
98- }
99- child := &LayerSurface{}
100- child.SetContext(o.Context())
101- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 0, Name: "zwlr_layer_shell_v1.get_layer_surface", Child: child}, child, arg1, arg2, layer, namespace); err != nil {
102- return nil, err
103- }
104- return child, nil
105-}
106-
107-// This request indicates that the client will not use the layer_shell object any more. Objects that have been created through this instance are not affected.
108-func (o *LayerShell) Destroy() error {
109- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 1, Name: "zwlr_layer_shell_v1.destroy", Since: 3, Destructor: true}); err != nil {
110- return err
111- }
112- return nil
113-}
114-
115-// EventSignature returns the declared event argument types in wire order.
116-func (o *LayerShell) EventSignature(opcode uint16) (string, bool) {
117- switch opcode {
118- default:
119- return "", false
120- }
121-}
122-
123-// Dispatch decodes one event on zwlr_layer_shell_v1 and calls the registered handlers.
124-func (o *LayerShell) Dispatch(event *wl.Event) {
125- switch event.Opcode {
126- default:
127- }
128-}
129-
130-// LayerSurfaceInterface is the protocol interface name.
131-const LayerSurfaceInterface = "zwlr_layer_surface_v1"
132-
133-// LayerSurface is the generated binding for zwlr_layer_surface_v1 version 5.
134-type LayerSurface struct {
135- wl.BaseProxy
136- // handlersMu guards the handler slices: a client may register or replace
137- // handlers while another goroutine dispatches events.
138- handlersMu sync.Mutex
139- onConfigure []func(serial uint32, width uint32, height uint32)
140- onClosed []func()
141-}
142-
143-// NewLayerSurface returns an unbound LayerSurface.
144-func NewLayerSurface(ctx *wl.Context) *LayerSurface {
145- obj := &LayerSurface{}
146- obj.SetContext(ctx)
147- ctx.Register(obj)
148- return obj
149-}
150-
151-// Sets the size of the surface in surface-local coordinates. The compositor will display the surface centered with respect to its anchors. If you pass 0 for either value, the compositor will assign it and inform you of the assignment in the configure event. You must set your anchor to opposite edges in the dimensions you omit; not doing so is a protocol error. Both values are 0 by default. Size is double-buffered, see wl_surface.commit.
152-func (o *LayerSurface) SetSize(width uint32, height uint32) error {
153- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 0, Name: "zwlr_layer_surface_v1.set_size"}, width, height); err != nil {
154- return err
155- }
156- return nil
157-}
158-
159-// Requests that the compositor anchor the surface to the specified edges and corners. If two orthogonal edges are specified (e.g. 'top' and 'left'), then the anchor point will be the intersection of the edges (e.g. the top left corner of the output); otherwise the anchor point will be centered on that edge, or in the center if none is specified. Anchor is double-buffered, see wl_surface.commit.
160-func (o *LayerSurface) SetAnchor(anchor uint32) error {
161- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 1, Name: "zwlr_layer_surface_v1.set_anchor"}, anchor); err != nil {
162- return err
163- }
164- return nil
165-}
166-
167-// Requests that the compositor avoids occluding an area with other surfaces. The compositor's use of this information is implementation-dependent - do not assume that this region will not actually be occluded. A positive value is only meaningful if the surface is anchored to one edge or an edge and both perpendicular edges. If the surface is not anchored, anchored to only two perpendicular edges (a corner), anchored to only two parallel edges or anchored to all edges, a positive value will be treated the same as zero. A positive zone is the distance from the edge in surface-local coordinates to consider exclusive. Surfaces that do not wish to have an exclusive zone may instead specify how they should interact with surfaces that do. If set to zero, the surface indicates that it would like to be moved to avoid occluding surfaces with a positive exclusive zone. If set to -1, the surface indicates that it would not like to be moved to accommodate for other surfaces, and the compositor should extend it all the way to the edges it is anchored to. For example, a panel might set its exclusive zone to 10, so that maximized shell surfaces are not shown on top of it. A notification might set its exclusive zone to 0, so that it is moved to avoid occluding the panel, but shell surfaces are shown underneath it. A wallpaper or lock screen might set their exclusive zone to -1, so that they stretch below or over the panel. The default value is 0. Exclusive zone is double-buffered, see wl_surface.commit.
168-func (o *LayerSurface) SetExclusiveZone(zone int32) error {
169- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 2, Name: "zwlr_layer_surface_v1.set_exclusive_zone"}, zone); err != nil {
170- return err
171- }
172- return nil
173-}
174-
175-// Requests that the surface be placed some distance away from the anchor point on the output, in surface-local coordinates. Setting this value for edges you are not anchored to has no effect. The exclusive zone includes the margin. Margin is double-buffered, see wl_surface.commit.
176-func (o *LayerSurface) SetMargin(top int32, right int32, bottom int32, left int32) error {
177- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 3, Name: "zwlr_layer_surface_v1.set_margin"}, top, right, bottom, left); err != nil {
178- return err
179- }
180- return nil
181-}
182-
183-// Set how keyboard events are delivered to this surface. By default, layer shell surfaces do not receive keyboard events; this request can be used to change this. This setting is inherited by child surfaces set by the get_popup request. Layer surfaces receive pointer, touch, and tablet events normally. If you do not want to receive them, set the input region on your surface to an empty region. Keyboard interactivity is double-buffered, see wl_surface.commit.
184-func (o *LayerSurface) SetKeyboardInteractivity(keyboardInteractivity uint32) error {
185- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 4, Name: "zwlr_layer_surface_v1.set_keyboard_interactivity"}, keyboardInteractivity); err != nil {
186- return err
187- }
188- return nil
189-}
190-
191-// This assigns an xdg_popup's parent to this layer_surface. This popup should have been created via xdg_surface::get_popup with the parent set to NULL, and this request must be invoked before committing the popup's initial state. See the documentation of xdg_popup for more details about what an xdg_popup is and how it is used.
192-func (o *LayerSurface) GetPopup(popup *cross_xdg_popup.XdgPopup) error {
193- var arg0 wl.Object
194- if popup != nil {
195- arg0 = popup
196- }
197- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 5, Name: "zwlr_layer_surface_v1.get_popup"}, arg0); err != nil {
198- return err
199- }
200- return nil
201-}
202-
203-// When a configure event is received, if a client commits the surface in response to the configure event, then the client must make an ack_configure request sometime before the commit request, passing along the serial of the configure event. If the client receives multiple configure events before it can respond to one, it only has to ack the last configure event. A client is not required to commit immediately after sending an ack_configure request - it may even ack_configure several times before its next surface commit. A client may send multiple ack_configure requests before committing, but only the last request sent before a commit indicates which configure event the client really is responding to.
204-func (o *LayerSurface) AckConfigure(serial uint32) error {
205- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 6, Name: "zwlr_layer_surface_v1.ack_configure"}, serial); err != nil {
206- return err
207- }
208- return nil
209-}
210-
211-// This request destroys the layer surface.
212-func (o *LayerSurface) Destroy() error {
213- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 7, Name: "zwlr_layer_surface_v1.destroy", Destructor: true}); err != nil {
214- return err
215- }
216- return nil
217-}
218-
219-// Change the layer that the surface is rendered on. Layer is double-buffered, see wl_surface.commit.
220-func (o *LayerSurface) SetLayer(layer uint32) error {
221- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 8, Name: "zwlr_layer_surface_v1.set_layer", Since: 2}, layer); err != nil {
222- return err
223- }
224- return nil
225-}
226-
227-// Requests an edge for the exclusive zone to apply. The exclusive edge will be automatically deduced from anchor points when possible, but when the surface is anchored to a corner, it will be necessary to set it explicitly to disambiguate, as it is not possible to deduce which one of the two corner edges should be used. The edge must be one the surface is anchored to, otherwise the invalid_exclusive_edge protocol error will be raised.
228-func (o *LayerSurface) SetExclusiveEdge(edge uint32) error {
229- if err := o.Context().Request(wl.Request{Proxy: o, Opcode: 9, Name: "zwlr_layer_surface_v1.set_exclusive_edge", Since: 5}, edge); err != nil {
230- return err
231- }
232- return nil
233-}
234-
235-// OnConfigure registers a handler for the configure event.
236-//
237-// Handlers are appended, so a second call adds another handler rather than
238-// replacing the first. Registration is safe while another goroutine dispatches.
239-func (o *LayerSurface) OnConfigure(handler func(serial uint32, width uint32, height uint32)) {
240- if handler == nil {
241- return
242- }
243- o.handlersMu.Lock()
244- defer o.handlersMu.Unlock()
245- o.onConfigure = append(o.onConfigure, handler)
246-}
247-
248-// handlersForConfigure returns the handlers registered for configure, taken
249-// under the lock so a handler may register another handler while events are
250-// being dispatched.
251-func (o *LayerSurface) handlersForConfigure() []func(serial uint32, width uint32, height uint32) {
252- o.handlersMu.Lock()
253- defer o.handlersMu.Unlock()
254- return o.onConfigure
255-}
256-
257-// OnClosed registers a handler for the closed event.
258-//
259-// Handlers are appended, so a second call adds another handler rather than
260-// replacing the first. Registration is safe while another goroutine dispatches.
261-func (o *LayerSurface) OnClosed(handler func()) {
262- if handler == nil {
263- return
264- }
265- o.handlersMu.Lock()
266- defer o.handlersMu.Unlock()
267- o.onClosed = append(o.onClosed, handler)
268-}
269-
270-// handlersForClosed returns the handlers registered for closed, taken
271-// under the lock so a handler may register another handler while events are
272-// being dispatched.
273-func (o *LayerSurface) handlersForClosed() []func() {
274- o.handlersMu.Lock()
275- defer o.handlersMu.Unlock()
276- return o.onClosed
277-}
278-
279-// EventSignature returns the declared event argument types in wire order.
280-func (o *LayerSurface) EventSignature(opcode uint16) (string, bool) {
281- switch opcode {
282- case 0:
283- return "uint,uint,uint,", true
284- case 1:
285- return "", true
286- default:
287- return "", false
288- }
289-}
290-
291-// Dispatch decodes one event on zwlr_layer_surface_v1 and calls the registered handlers.
292-func (o *LayerSurface) Dispatch(event *wl.Event) {
293- switch event.Opcode {
294- case 0: // configure
295- serial := event.Uint32()
296- width := event.Uint32()
297- height := event.Uint32()
298-
299- for i, handler := range o.handlersForConfigure() {
300- _ = i
301- handler(serial, width, height)
302- }
303- case 1: // closed
304-
305- for i, handler := range o.handlersForClosed() {
306- _ = i
307- handler()
308- }
309- default:
310- }
311-}
D internal/platform/wayland/lock.go +0 -32
@@ -1,32 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import "fmt"
6-
7-// LockOptions selects one registry output lifetime returned by Outputs.
8-// The lock object is deliberately private to Connection, never passed by UI.
9-type LockOptions struct{ Output uint32 }
10-
11-func (w *Window) createLockSurface(opts LockOptions) error {
12- c := w.connection
13- if c == nil || c.lock == nil {
14- return fmt.Errorf("lock window requires acquired connection lock")
15- }
16- out, ok := c.outputs[opts.Output]
17- if !ok || out.proxy == nil {
18- return fmt.Errorf("lock output %d unavailable", opts.Output)
19- }
20- role, err := c.lock.GetLockSurface(w.Surface, out.proxy)
21- if err != nil {
22- return err
23- }
24- w.LockSurface, w.lockOutput = role, opts.Output
25- role.OnConfigure(func(serial, width, height uint32) {
26- w.post(Event{Kind: ConfigureLock, Serial: serial, Width: int32(width), Height: int32(height)})
27- })
28- return nil
29-}
30-
31-// LockOutput is the registry lifetime this lock surface covers.
32-func (w *Window) LockOutput() uint32 { return w.lockOutput }
D internal/platform/wayland/sequence_test.go +0 -131
@@ -1,131 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "encoding/binary"
7- "io"
8- "net"
9- "testing"
10- "time"
11-
12- "github.com/bnema/nefergui/internal/presentation/buffers"
13- "github.com/bnema/wlturbo"
14- "github.com/bnema/wlturbo/protocol/core"
15- "github.com/bnema/wlturbo/protocol/drmsyncobj"
16- "github.com/bnema/wlturbo/wl"
17-)
18-
19-// A socket peer records real wire requests (not a mocked Present implementation).
20-// Every frame must carry both sync points and a buffer on the same commit.
21-func TestPresentWireSequence(t *testing.T) {
22- client, server := net.Pipe()
23- defer server.Close()
24- // ConnectFromConn sends get_registry before it returns: drain the pipe first.
25- type request struct {
26- id uint32
27- opcode uint16
28- args []byte
29- }
30- requests := make(chan request, 64)
31- errs := make(chan error, 1)
32- go func() {
33- for {
34- header := make([]byte, 8)
35- if _, e := io.ReadFull(server, header); e != nil {
36- errs <- e
37- return
38- }
39- n := int(binary.LittleEndian.Uint32(header[4:]) >> 16)
40- data := make([]byte, n-8)
41- if _, e := io.ReadFull(server, data); e != nil {
42- errs <- e
43- return
44- }
45- requests <- request{binary.LittleEndian.Uint32(header), binary.LittleEndian.Uint16(header[4:]), data}
46- }
47- }()
48- d, err := wlturbo.ConnectFromConn(client)
49- if err != nil {
50- t.Fatal(err)
51- }
52- defer d.Close()
53- ctx := d.Context()
54- surface := core.NewSurface(ctx)
55- syncSurface := drmsyncobj.NewWpLinuxDrmSyncobjSurface(ctx)
56- acquire := drmsyncobj.NewWpLinuxDrmSyncobjTimeline(ctx)
57- release := drmsyncobj.NewWpLinuxDrmSyncobjTimeline(ctx)
58- buffer := core.NewBuffer(ctx)
59- for i, p := range []wl.Proxy{surface, syncSurface, acquire, release, buffer} {
60- p.SetID(uint32(20 + i))
61- ctx.Register(p)
62- }
63- w := &Window{Surface: surface, SyncSurface: syncSurface, Width: 40, Height: 30, Scale: 1, Configured: true, FeedbackDone: true, FrameReady: true, Transparent: true}
64- p, _ := buffers.New(1)
65- // The fake server receives a finite number of requests; no dispatch loop/GPU.
66- for frame := 0; frame < 2; frame++ {
67- b, e := p.Begin()
68- if e != nil {
69- t.Fatal(e)
70- }
71- a, e := p.Acquire(b)
72- if e != nil {
73- t.Fatal(e)
74- }
75- z, e := p.Commit(b)
76- if e != nil {
77- t.Fatal(e)
78- }
79- if e = w.Present(buffer, acquire, release, a, z); e != nil {
80- t.Fatal(e)
81- }
82- if e = p.Own(b); e != nil {
83- t.Fatal(e)
84- }
85- // Observe the generated protocol sequence up to commit. Ignore ancillary
86- // viewport/opaque/damage/frame requests, but ensure they do not replace the buffer.
87- var events []string
88- var attached, acquired, released bool
89- deadline := time.After(3 * time.Second)
90- loop:
91- for {
92- select {
93- case q := <-requests:
94- switch {
95- case q.id == syncSurface.ID() && q.opcode == 1:
96- acquired = true
97- events = append(events, "acquire")
98- case q.id == syncSurface.ID() && q.opcode == 2:
99- released = true
100- events = append(events, "release")
101- case q.id == surface.ID() && q.opcode == 1:
102- attached = binary.LittleEndian.Uint32(q.args) == buffer.ID()
103- events = append(events, "attach")
104- case q.id == surface.ID() && q.opcode == 6:
105- events = append(events, "commit")
106- break loop
107- }
108- case e := <-errs:
109- t.Fatal(e)
110- case <-deadline:
111- t.Fatal("missing commit")
112- }
113- }
114- if !attached || !acquired || !released || len(events) != 4 || events[0] != "acquire" || events[1] != "release" || events[2] != "attach" || events[3] != "commit" {
115- t.Fatalf("frame %d wire sequence %v attached=%v", frame, events, attached)
116- }
117- if e = p.Release(b); e != nil {
118- t.Fatal(e)
119- }
120- if _, e = p.Begin(); e != buffers.ErrNoBuffer {
121- t.Fatalf("wl_buffer.release reused buffer: %v", e)
122- }
123- if e = p.Signal(b, z); e != nil {
124- t.Fatal(e)
125- }
126- if e = p.Reuse(b, true); e != nil {
127- t.Fatal(e)
128- }
129- w.FrameReady = true // synthetic frame callback
130- }
131-}
D internal/platform/wayland/size_test.go +0 -39
@@ -1,39 +0,0 @@
1-//go:build linux
2-
3-package wayland
4-
5-import (
6- "fmt"
7- "testing"
8-
9- "github.com/bnema/wlturbo/protocol/viewporter"
10-)
11-
12-func TestPhysicalSizeCeilsFractionalScale(t *testing.T) {
13- for _, tc := range []struct {
14- width, height int32
15- scale float64
16- wantW, wantH int32
17- }{
18- {3, 5, 1.25, 4, 7}, {3, 5, 1.5, 5, 8}, {4, 6, 1.5, 6, 9}, {3, 5, 1.25 + 1e-12, 4, 7},
19- } {
20- w := &Window{Width: tc.width, Height: tc.height, Scale: tc.scale, Viewport: &viewporter.WpViewport{}}
21- x, y, err := w.PhysicalSize()
22- if err != nil || x != tc.wantW || y != tc.wantH {
23- t.Fatalf("%dx%d scale %g: %dx%d err=%v; want %dx%d", tc.width, tc.height, tc.scale, x, y, err, tc.wantW, tc.wantH)
24- }
25- }
26-}
27-
28-func TestFeedbackAppliedOnLoop(t *testing.T) {
29- w := &Window{events: make(chan Event, 2), readerStop: make(chan struct{})}
30- w.readerStarted.Store(true)
31- go w.feedbackFailure(fmt.Errorf("feedback failed"))
32- ev := <-w.events
33- if w.feedbackErr != nil {
34- t.Fatal("reader mutated loop-owned error")
35- }
36- if err := w.Apply(ev); err == nil || w.feedbackErr == nil {
37- t.Fatalf("apply: %v feedback: %v", err, w.feedbackErr)
38- }
39-}
D internal/platform/wayland/window.go +0 -721
@@ -1,721 +0,0 @@
1-//go:build linux
2-
3-// Package wayland owns the minimal xdg-shell window and presentation protocol
4-// objects. Vulkan allocations and DMA-BUF image creation remain the renderer's
5-// responsibility; a window may not attach until the first configure is acked.
6-package wayland
7-
8-import (
9- "context"
10- "encoding/binary"
11- "errors"
12- "fmt"
13- "math"
14- "os"
15- "path/filepath"
16- "sync"
17- "sync/atomic"
18- "time"
19-
20- "github.com/bnema/wlturbo"
21- "github.com/bnema/wlturbo/protocol/core"
22- "github.com/bnema/wlturbo/protocol/cursorshape"
23- "github.com/bnema/wlturbo/protocol/drmsyncobj"
24- "github.com/bnema/wlturbo/protocol/extsessionlock"
25- "github.com/bnema/wlturbo/protocol/fractionalscale"
26- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
27- "github.com/bnema/wlturbo/protocol/viewporter"
28- "github.com/bnema/wlturbo/protocol/xdgshell"
29- "github.com/bnema/wlturbo/wl"
30-
31- "github.com/bnema/nefergui/internal/platform/wayland/layershell"
32-)
33-
34-// CapabilityError reports a missing or insufficient required protocol.
35-type CapabilityError struct {
36- Name string
37- Cause error
38-}
39-
40-func (e *CapabilityError) Error() string {
41- return fmt.Sprintf("Wayland capability %s: %v", e.Name, e.Cause)
42-}
43-func (e *CapabilityError) Unwrap() error { return e.Cause }
44-
45-const (
46- DRMXRGB8888 = uint32(0x34325258)
47- DRMARGB8888 = uint32(0x34325241)
48-)
49-
50-type Window struct {
51- connection *Connection
52- ownsConnection bool
53- LockSurface *extsessionlock.ExtSessionLockSurface
54- Display *wl.Display
55- Compositor *core.Compositor
56- Shell *xdgshell.XdgWmBase
57- Dmabuf *linuxdmabuf.LinuxDmabuf
58- SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager
59- LayerShell *layershell.LayerShell // nil for xdg windows
60- LayerSurface *layershell.LayerSurface // nil for xdg windows
61- Surface *core.Surface
62- XdgSurface *xdgshell.XdgSurface
63- Toplevel *xdgshell.XdgToplevel
64- SyncSurface *drmsyncobj.WpLinuxDrmSyncobjSurface
65- Viewporter *viewporter.WpViewporter
66- Viewport *viewporter.WpViewport
67- ScaleManager *fractionalscale.WpFractionalScaleManager
68- Fractional *fractionalscale.WpFractionalScale
69- Feedback *linuxdmabuf.LinuxDmabufFeedback
70- Seat *core.Seat
71- Pointer *core.Pointer
72- CursorShapes *cursorshape.WpCursorShapeManager // optional
73- cursor cursorState
74- pointerGen uint64 // bumped per wl_pointer; session loop only
75- WLKeyboard *core.Keyboard
76- keymapCache *keyboardMapCache // owner retires each keyboard generation
77- seatName, seatVersion uint32
78- Width, Height int32
79- Scale float64
80- Configured, Closed, Dirty bool
81- Transparent bool
82- FrameReady bool
83- // Feedback fields are populated by the default feedback event stream.
84- FeedbackDone bool // owner has applied the complete feedback round
85- MainDevice uint64
86- Formats []linuxdmabuf.FormatEntry
87- Tranches [][]linuxdmabuf.FormatEntry
88- feedbackErr error
89- events chan Event
90- readerDone chan struct{}
91- readerStop chan struct{}
92- readerOnce sync.Once
93- readerStarted atomic.Bool
94- rawInput []Event // lock-only numeric protocol events, never ordinary text
95- inputAdapter *InputAdapter
96- inputQueue []InputEvent
97- // inputRects/inputCustom record the current input region; session loop only.
98- inputRects []Rect
99- inputCustom bool
100- layerOutputGlobal uint32
101- lockOutput uint32
102- layerOutputSelected bool
103- // InputOverflow counts events discarded when the 4096-event queue fills.
104- InputOverflow uint64
105-}
106-
107-// Connect opens a default xdg-toplevel window with full-surface input. It
108-// cannot be cancelled; use ConnectWithOptions for a cancellable startup.
109-func Connect(name string, width, height int32, transparent bool) (*Window, error) {
110- return ConnectWithOptions(context.Background(), name, width, height, transparent, SurfaceOptions{})
111-}
112-
113-// dialTimeout bounds the unix-socket dial; a local dial is normally instant.
114-const dialTimeout = 5 * time.Second
115-
116-// waylandSocketPath resolves name like wlturbo.Connect does.
117-func waylandSocketPath(name string) (string, error) {
118- if name == "" {
119- name = os.Getenv("WAYLAND_DISPLAY")
120- if name == "" {
121- name = "wayland-0"
122- }
123- }
124- if filepath.IsAbs(name) {
125- return name, nil
126- }
127- runDir := os.Getenv("XDG_RUNTIME_DIR")
128- if runDir == "" {
129- return "", errors.New("XDG_RUNTIME_DIR not set")
130- }
131- return filepath.Join(runDir, name), nil
132-}
133-
134-// ConnectWithOptions opens a window whose role and initial input region are
135-// selected by opts. The zero SurfaceOptions is identical to Connect. Startup
136-// (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the
137-// connection is closed and the returned error wraps ctx.Err(). Once it
138-// returns successfully, ctx no longer affects the window.
139-func ConnectWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts SurfaceOptions) (*Window, error) {
140- if ctx == nil {
141- return nil, errors.New("nil context")
142- }
143- if width <= 0 || height <= 0 {
144- return nil, fmt.Errorf("invalid logical size %dx%d", width, height)
145- }
146- if err := opts.Validate(); err != nil {
147- return nil, err
148- }
149- c, err := ConnectConnection(ctx, name)
150- if err != nil {
151- return nil, err
152- }
153- w, err := c.NewWindow(ctx, width, height, transparent, opts)
154- if err != nil {
155- _ = c.Close()
156- return nil, err
157- }
158- w.ownsConnection = true
159- return w, nil
160-}
161-
162-// NewWindow creates a sibling on this connection. Before StartReader it waits
163-// for initial configure/feedback; afterwards it returns unconfigured and the
164-// owner must Apply tagged events before allocating/presenting buffers.
165-func (c *Connection) NewWindow(ctx context.Context, width, height int32, transparent bool, opts SurfaceOptions) (res *Window, err error) {
166- if ctx == nil {
167- return nil, errors.New("nil context")
168- }
169- if width <= 0 || height <= 0 {
170- return nil, fmt.Errorf("invalid logical size %dx%d", width, height)
171- }
172- if err = opts.Validate(); err != nil {
173- return nil, err
174- }
175- if err = ctx.Err(); err != nil {
176- return nil, err
177- }
178- if c.closed {
179- return nil, errors.New("connection closed")
180- }
181- if opts.Lock != nil && (transparent || opts.Layer != nil || opts.InputRects != nil) {
182- return nil, errors.New("lock role requires opaque full-input surface")
183- }
184- if opts.Lock != nil && !c.started.Load() {
185- // A lock that was already refused or ended must not get surfaces; its
186- // lock and output events are held for the owner, not applied here.
187- if err = c.drainStartup(true); err != nil {
188- return nil, err
189- }
190- }
191- // Objects created below get their handlers after their request is sent;
192- // a running reader must not dispatch their first events in between.
193- defer c.pauseReader()()
194- d := c.Display
195- // Closing the display unblocks any pending read; Display.Close is idempotent.
196- stopWatch := context.AfterFunc(ctx, func() { _ = d.Close() })
197- w := &Window{connection: c, events: c.events, readerStop: c.stop, Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent}
198- w.readerStarted.Store(c.started.Load())
199- defer func() {
200- if !stopWatch() && err == nil {
201- // Cancelled after the last step but before return: the watcher owns a
202- // closed display, so the window is unusable.
203- err = ctx.Err()
204- }
205- if err != nil {
206- if cerr := ctx.Err(); cerr != nil && !errors.Is(err, cerr) {
207- err = errors.Join(cerr, err)
208- }
209- _ = w.Close()
210- res = nil
211- }
212- }()
213- wctx := d.Context()
214- bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error {
215- advertised, ok := d.Registry().FindGlobal(name)
216- if !ok || advertised.Version < minimum {
217- return &CapabilityError{Name: name, Cause: fmt.Errorf("requires version %d, announced %d", minimum, advertised.Version)}
218- }
219- got, e := d.Registry().BindNegotiated(name, supported, proxy)
220- if e != nil {
221- return &CapabilityError{Name: name, Cause: e}
222- }
223- if got < minimum {
224- return &CapabilityError{Name: name, Cause: fmt.Errorf("requires version %d, compositor negotiated %d", minimum, got)}
225- }
226- return nil
227- }
228- w.Compositor = core.NewCompositor(wctx)
229- if err = bind(core.CompositorInterface, 6, 4, w.Compositor); err != nil {
230- return nil, err
231- }
232- if opts.Layer != nil {
233- if err = w.bindLayerShell(bind); err != nil {
234- return nil, err
235- }
236- } else if opts.Lock == nil {
237- w.Shell = xdgshell.NewXdgWmBase(wctx)
238- if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil {
239- return nil, err
240- }
241- }
242- w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(wctx)
243- if err = bind(linuxdmabuf.LinuxDmabufInterface, 4, 4, w.Dmabuf); err != nil {
244- return nil, err
245- }
246- w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(wctx)
247- if err = bind(drmsyncobj.WpLinuxDrmSyncobjManagerInterface, 1, 1, w.SyncManager); err != nil {
248- return nil, err
249- }
250- w.Viewporter = viewporter.NewWpViewporter(wctx)
251- if _, e := d.Registry().BindNegotiated(viewporter.WpViewporterInterface, 1, w.Viewporter); e != nil {
252- if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
253- return nil, e
254- }
255- wctx.Unregister(w.Viewporter)
256- w.Viewporter = nil
257- }
258- w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(wctx)
259- if _, e := d.Registry().BindNegotiated(fractionalscale.WpFractionalScaleManagerInterface, 1, w.ScaleManager); e != nil {
260- if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
261- return nil, e
262- }
263- wctx.Unregister(w.ScaleManager)
264- w.ScaleManager = nil
265- }
266- w.CursorShapes = cursorshape.NewWpCursorShapeManager(wctx)
267- if _, e := d.Registry().BindNegotiated(cursorshape.WpCursorShapeManagerInterface, 1, w.CursorShapes); e != nil {
268- if !errors.Is(e, wlturbo.ErrGlobalNotFound) {
269- return nil, e
270- }
271- wctx.Unregister(w.CursorShapes)
272- w.CursorShapes = nil
273- }
274- w.Seat, w.Pointer, w.WLKeyboard = c.seat.Seat, c.seat.Pointer, c.seat.WLKeyboard
275- if opts.Lock != nil {
276- w.Seat = nil
277- } // clipboard/IME constructors require Seat
278- w.pointerGen = c.seat.pointerGen
279- if w.Shell != nil {
280- w.Shell.OnPing(func(serial uint32) {
281- if e := w.Shell.Pong(serial); e != nil {
282- w.feedbackFailure(e)
283- }
284- })
285- }
286- w.Surface, err = w.Compositor.CreateSurface()
287- if err != nil {
288- return nil, err
289- }
290- if w.Viewporter != nil {
291- w.Viewport, err = w.Viewporter.GetViewport(w.Surface)
292- if err != nil {
293- return nil, err
294- }
295- }
296- if w.ScaleManager != nil {
297- w.Fractional, err = w.ScaleManager.GetFractionalScale(w.Surface)
298- if err != nil {
299- return nil, err
300- }
301- w.Fractional.OnPreferredScale(func(scale uint32) {
302- if w.readerStarted.Load() {
303- w.post(Event{Kind: PreferredScale, Scale: scale})
304- return
305- }
306- if scale == 0 {
307- w.feedbackFailure(fmt.Errorf("invalid fractional scale zero"))
308- return
309- }
310- if scale%120 != 0 && w.Viewport == nil {
311- w.feedbackFailure(&CapabilityError{Name: viewporter.WpViewporterInterface, Cause: fmt.Errorf("fractional scale %d/120 requires viewporter", scale)})
312- return
313- }
314- if w.Scale != float64(scale)/120 {
315- w.Scale = float64(scale) / 120
316- w.Dirty = true
317- }
318- })
319- }
320- w.Surface.OnPreferredBufferScale(func(factor int32) {
321- if w.readerStarted.Load() {
322- w.post(Event{Kind: BufferScale, Factor: factor})
323- return
324- }
325- if w.Fractional == nil && factor > 0 && w.Scale != float64(factor) {
326- w.Scale = float64(factor)
327- w.Dirty = true
328- }
329- })
330- c.windows[w.Surface.ID()] = w
331- c.routes.Store(w.Surface.ID(), w)
332- if opts.Lock != nil {
333- if err = w.createLockSurface(*opts.Lock); err != nil {
334- return nil, err
335- }
336- } else if opts.Layer != nil {
337- if err = w.createLayerSurface(*opts.Layer); err != nil {
338- return nil, err
339- }
340- } else if err = w.createToplevel(); err != nil {
341- return nil, err
342- }
343- if opts.InputRects != nil {
344- if err = w.SetInputRects(opts.InputRects); err != nil {
345- return nil, err
346- }
347- }
348- w.SyncSurface, err = w.SyncManager.GetSurface(w.Surface)
349- if err != nil {
350- return nil, err
351- }
352- w.Feedback, err = w.Dmabuf.GetDefaultFeedback()
353- if err != nil {
354- return nil, err
355- }
356- w.watchFeedback()
357- // ext-session-lock configure is sent by get_lock_surface; unlike xdg and
358- // layer-shell it forbids the initial empty commit before ACK+buffer.
359- if opts.Lock == nil {
360- if err = w.Surface.Commit(); err != nil {
361- return nil, err
362- }
363- }
364- if c.started.Load() {
365- return w, nil
366- }
367- for !w.Configured || !w.FeedbackDone || w.MainDevice == 0 || len(w.Tranches) == 0 {
368- if w.Closed {
369- return nil, fmt.Errorf("window closed before initial configure and feedback")
370- }
371- if err = c.drainStartup(true); err != nil {
372- return nil, err // a finished already queued: do not wait for a configure
373- }
374- if err = w.Dispatch(); err != nil {
375- return nil, err
376- }
377- if err = c.drainStartup(true); err != nil {
378- return nil, err
379- }
380- }
381- return w, nil
382-}
383-
384-func (w *Window) createToplevel() error {
385- var err error
386- w.XdgSurface, err = w.Shell.GetXdgSurface(w.Surface)
387- if err != nil {
388- return err
389- }
390- w.Toplevel, err = w.XdgSurface.GetToplevel()
391- if err != nil {
392- return err
393- }
394- w.Toplevel.OnClose(func() {
395- if w.readerStarted.Load() {
396- w.post(Event{Kind: CloseEvent})
397- return
398- }
399- w.Closed = true
400- })
401- w.Toplevel.OnConfigure(func(width, height int32, _ []byte) {
402- if w.readerStarted.Load() {
403- w.post(Event{Kind: ConfigureSize, Width: width, Height: height})
404- return
405- }
406- if width > 0 && height > 0 && (w.Width != width || w.Height != height) {
407- w.Width, w.Height, w.Dirty = width, height, true
408- }
409- })
410- w.XdgSurface.OnConfigure(func(serial uint32) {
411- if w.readerStarted.Load() {
412- w.post(Event{Kind: ConfigureSerial, Serial: serial})
413- return
414- }
415- if e := w.XdgSurface.AckConfigure(serial); e != nil {
416- w.feedbackFailure(e)
417- return
418- }
419- w.Configured, w.FrameReady = true, true
420- })
421- return nil
422-}
423-
424-func (w *Window) Dispatch() error {
425- if w.connection != nil && w.connection.started.Load() {
426- return errors.New("connection reader already started")
427- }
428- if w.feedbackErr != nil {
429- return w.feedbackErr
430- }
431- if err := w.Display.Dispatch(); err != nil {
432- return err
433- }
434- return w.feedbackErr
435-}
436-
437-// Ready reports that a buffer may be allocated and attached: the role's
438-// configure was acknowledged, the dmabuf feedback round is complete and the
439-// previous frame callback has fired.
440-func (w *Window) Ready() bool { return w.Configured && w.FeedbackDone && w.FrameReady }
441-
442-// IsLock reports whether the window is an ext-session-lock surface.
443-func (w *Window) IsLock() bool { return w != nil && w.LockSurface != nil }
444-
445-func (w *Window) PhysicalSize() (int32, int32, error) {
446- if w.Scale <= 0 || w.Width <= 0 || w.Height <= 0 {
447- return 0, 0, fmt.Errorf("invalid window geometry")
448- }
449- if w.Scale != math.Trunc(w.Scale) && w.Viewport == nil {
450- return 0, 0, &CapabilityError{Name: viewporter.WpViewporterInterface, Cause: fmt.Errorf("fractional scale %g requires viewporter", w.Scale)}
451- }
452- // Ignore sub-nanopixel floating noise at exact integer boundaries.
453- x, y := math.Ceil(float64(w.Width)*w.Scale-1e-9), math.Ceil(float64(w.Height)*w.Scale-1e-9)
454- if x > math.MaxInt32 || y > math.MaxInt32 {
455- return 0, 0, fmt.Errorf("buffer dimensions overflow")
456- }
457- return int32(x), int32(y), nil
458-}
459-
460-// Present sets both syncobj points on every buffer commit. The caller supplies
461-// an already-imported wl_buffer and two persistent Wayland timeline objects.
462-func (w *Window) Present(buffer *core.Buffer, acquire, release *drmsyncobj.WpLinuxDrmSyncobjTimeline, acquirePoint, releasePoint uint64) error {
463- if !w.Configured || buffer == nil || acquire == nil || release == nil {
464- return fmt.Errorf("unconfigured surface or missing buffer/timeline")
465- }
466- if !w.FeedbackDone {
467- return fmt.Errorf("dmabuf feedback incomplete")
468- }
469- if !w.FrameReady {
470- return fmt.Errorf("frame callback pending")
471- }
472- pw, ph, err := w.PhysicalSize()
473- if err != nil {
474- return err
475- }
476- if w.Viewport != nil {
477- if err = w.Viewport.SetDestination(w.Width, w.Height); err != nil {
478- return err
479- }
480- } else if w.Scale > 1 {
481- if err = w.Surface.SetBufferScale(int32(w.Scale)); err != nil {
482- return err
483- }
484- }
485- if w.Transparent {
486- if err = w.Surface.SetOpaqueRegion(nil); err != nil {
487- return err
488- }
489- } else {
490- region, e := w.Compositor.CreateRegion()
491- if e != nil {
492- return e
493- }
494- if err = region.Add(0, 0, w.Width, w.Height); err == nil {
495- err = w.Surface.SetOpaqueRegion(region)
496- }
497- _ = region.Destroy()
498- if err != nil {
499- return err
500- }
501- }
502- if err = w.SyncSurface.SetAcquirePoint(acquire, uint32(acquirePoint>>32), uint32(acquirePoint)); err != nil {
503- return err
504- }
505- if err = w.SyncSurface.SetReleasePoint(release, uint32(releasePoint>>32), uint32(releasePoint)); err != nil {
506- return err
507- }
508- if err = w.Surface.Attach(buffer, 0, 0); err != nil {
509- return err
510- }
511- if err = w.Surface.DamageBuffer(0, 0, pw, ph); err != nil {
512- return err
513- }
514- cb, e := w.Surface.Frame()
515- if e != nil {
516- return e
517- }
518- cb.OnDone(func(_ uint32) {
519- if w.readerStarted.Load() {
520- w.post(Event{Kind: FrameEvent})
521- return
522- }
523- w.FrameReady = true
524- })
525- if err = w.Surface.Commit(); err != nil {
526- return err
527- }
528- w.FrameReady, w.Dirty = false, false
529- return nil
530-}
531-
532-// DestroySyncSurface stops associating new explicit-sync points with the surface.
533-// The session calls it after committing a null attachment at shutdown.
534-func (w *Window) DestroySyncSurface() error {
535- if w == nil || w.SyncSurface == nil {
536- return nil
537- }
538- err := w.SyncSurface.Destroy()
539- if err == nil {
540- w.SyncSurface = nil
541- }
542- return err
543-}
544-
545-// Close destroys children before their parents. The caller first detaches and
546-// waits for its own GPU work before destroying its Vulkan allocations.
547-func (w *Window) Close() error {
548- if w == nil {
549- return nil
550- }
551- if w.ownsConnection && w.connection != nil && !w.connection.closed {
552- return w.connection.Close()
553- }
554- for _, ev := range w.rawInput {
555- CloseEventFD(ev)
556- }
557- clear(w.rawInput)
558- w.rawInput = nil
559- // The loop sends protocol destructors before Close; the reader is
560- // unblocked by the connection close at the end of this method.
561- if w.inputAdapter != nil {
562- w.inputAdapter.Close()
563- w.inputAdapter = nil
564- }
565- if w.Seat != nil && w.connection == nil {
566- _ = w.releaseSeat()
567- }
568- w.dropCursorDevice()
569- if w.CursorShapes != nil {
570- _ = w.CursorShapes.Destroy()
571- w.CursorShapes = nil
572- }
573- if w.Feedback != nil {
574- _ = w.Feedback.Destroy()
575- w.Feedback = nil
576- }
577- if w.SyncSurface != nil {
578- _ = w.SyncSurface.Destroy()
579- w.SyncSurface = nil
580- }
581- if w.connection != nil && w.Surface != nil {
582- delete(w.connection.windows, w.Surface.ID())
583- w.connection.routes.Delete(w.Surface.ID())
584- }
585- if w.LockSurface != nil {
586- _ = w.LockSurface.Destroy()
587- w.LockSurface = nil
588- }
589- w.closeLayer() // before wl_surface
590- if w.Toplevel != nil {
591- _ = w.Toplevel.Destroy()
592- w.Toplevel = nil
593- }
594- if w.XdgSurface != nil {
595- _ = w.XdgSurface.Destroy()
596- w.XdgSurface = nil
597- }
598- if w.Fractional != nil {
599- _ = w.Fractional.Destroy()
600- w.Fractional = nil
601- }
602- if w.Viewport != nil {
603- _ = w.Viewport.Destroy()
604- w.Viewport = nil
605- }
606- if w.Surface != nil {
607- _ = w.Surface.Destroy()
608- w.Surface = nil
609- }
610- if w.SyncManager != nil {
611- _ = w.SyncManager.Destroy()
612- w.SyncManager = nil
613- }
614- if w.Dmabuf != nil {
615- _ = w.Dmabuf.Destroy()
616- w.Dmabuf = nil
617- }
618- if w.ScaleManager != nil {
619- _ = w.ScaleManager.Destroy()
620- w.ScaleManager = nil
621- }
622- if w.Viewporter != nil {
623- _ = w.Viewporter.Destroy()
624- w.Viewporter = nil
625- }
626- if w.Shell != nil {
627- _ = w.Shell.Destroy()
628- w.Shell = nil
629- }
630- // wl_compositor is bound at most at version 6; its release destructor
631- // needs version 7, so the proxy is only forgotten locally.
632- if w.Compositor != nil {
633- if c := w.Compositor.Context(); c != nil {
634- c.Unregister(w.Compositor)
635- }
636- w.Compositor = nil
637- }
638- if w.connection != nil {
639- w.Display = nil
640- w.Closed = true
641- if w.ownsConnection {
642- return w.connection.Close()
643- }
644- return nil
645- }
646- if w.Display != nil {
647- if w.readerDone != nil {
648- close(w.readerStop)
649- }
650- err := w.Display.Close()
651- if w.readerDone != nil {
652- <-w.readerDone
653- w.readerDone = nil
654- }
655- for len(w.events) > 0 {
656- CloseEventFD(<-w.events)
657- }
658- w.Display = nil
659- return err
660- }
661- return nil
662-}
663-
664-func (w *Window) watchFeedback() {
665- var table []linuxdmabuf.FormatEntry
666- var indices []uint16
667- w.Feedback.OnFormatTable(func(fd *wl.OwnedFD, size uint32) {
668- var err error
669- table, err = linuxdmabuf.ReadFormatTable(fd, size)
670- if err != nil {
671- w.feedbackFailure(err)
672- }
673- })
674- w.Feedback.OnMainDevice(func(device []byte) {
675- if len(device) != 8 {
676- w.feedbackFailure(fmt.Errorf("invalid dmabuf main_device length %d", len(device)))
677- return
678- }
679- if w.readerStarted.Load() {
680- w.post(Event{Kind: FeedbackMainDevice, Device: binary.NativeEndian.Uint64(device)})
681- } else {
682- w.MainDevice = binary.NativeEndian.Uint64(device)
683- }
684- })
685- w.Feedback.OnTrancheFormats(func(data []byte) {
686- var err error
687- indices, err = linuxdmabuf.TrancheIndices(data, len(table))
688- if err != nil {
689- w.feedbackFailure(err)
690- }
691- })
692- w.Feedback.OnTrancheDone(func() {
693- var tranche []linuxdmabuf.FormatEntry
694- for _, i := range indices {
695- tranche = append(tranche, table[i])
696- }
697- if w.readerStarted.Load() {
698- w.post(Event{Kind: FeedbackTranche, Tranche: tranche})
699- } else {
700- w.Tranches = append(w.Tranches, tranche)
701- }
702- indices = nil
703- })
704- w.Feedback.OnDone(func() {
705- if w.readerStarted.Load() {
706- w.post(Event{Kind: FeedbackFormats, Formats: table})
707- w.post(Event{Kind: FeedbackComplete})
708- } else {
709- w.Formats = table
710- w.FeedbackDone = true
711- }
712- })
713-}
714-
715-func (w *Window) feedbackFailure(err error) {
716- if w.readerStarted.Load() {
717- w.post(Event{Kind: FeedbackError, Err: err})
718- } else {
719- w.feedbackErr = err
720- }
721-}
D internal/presentation/session/app.go +0 -132
@@ -1,132 +0,0 @@
1-//go:build linux
2-
3-package session
4-
5-import (
6- "context"
7- "fmt"
8- "os"
9- "time"
10-
11- "github.com/bnema/nefergui/internal/platform/wayland"
12- "github.com/bnema/nefergui/internal/render"
13-)
14-
15-// AppHooks runs entirely on the session owner loop. Draw returns dirty=false
16-// when there is no new display list; the session retries a dirty frame until
17-// committed without waiting for GPU completion on the loop.
18-type AppHooks struct {
19- Wake <-chan struct{}
20- Input func([]wayland.Input) error
21- Event func(wayland.Event) error
22- Draw func() (render.Frame, bool, error)
23- Committed func(uint64) error
24-}
25-
26-// RunApp drives a UI until close or cancellation. Wayland input is batched after
27-// Apply, and wake tokens, repeat deadlines and release points share one select.
28-func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
29- if app.Draw == nil {
30- return fmt.Errorf("session: nil app draw")
31- }
32- if s.waitCancel != nil {
33- return fmt.Errorf("session already running")
34- }
35- s.waitCtx, s.waitCancel = context.WithCancel(ctx)
36- s.releases = make(chan releaseEvent, 128)
37- if s.debugDir != "" {
38- fds, _ := os.ReadDir("/proc/self/fd")
39- s.fdStart, s.fdPeak = len(fds), len(fds)
40- }
41- defer func() { s.waitCancel(); s.waiters.Wait() }()
42- wlEvents := s.Window.StartReader()
43- sched := newFrameSchedule()
44- for !s.Window.Closed {
45- if err := ctx.Err(); err != nil {
46- return err
47- }
48- pending, err := s.finishPending()
49- if err != nil {
50- return err
51- }
52- // Resize or scale change (also set before RunApp, or by the event case
53- // below): the prepared frame is stale until tick clears Window.Dirty.
54- if s.Window.Dirty {
55- sched.Invalidate()
56- }
57- if inputs := s.Window.DrainInput(nil); len(inputs) > 0 {
58- sched.Invalidate()
59- if app.Input != nil {
60- if err := app.Input(inputs); err != nil {
61- return err
62- }
63- }
64- }
65- committed, err := sched.Step(s.Window.Ready(), app.Draw, func(prepared render.Frame) (bool, error) {
66- return s.tickFrame(prepared)
67- })
68- if err != nil {
69- return err
70- }
71- if committed {
72- if app.Committed != nil {
73- if err := app.Committed(s.frame); err != nil {
74- return err
75- }
76- }
77- continue
78- }
79- var retry, repeat <-chan time.Time
80- if sched.NeedsRetry(pending || s.framePending, s.Window.Ready()) {
81- retry = time.After(retryInterval)
82- }
83- if adapter := s.Window.InputAdapter(); adapter != nil {
84- if at, ok := adapter.NextRepeat(); ok {
85- repeat = time.After(time.Until(at))
86- }
87- }
88- select {
89- case <-ctx.Done():
90- return ctx.Err()
91- case <-app.Wake:
92- sched.Invalidate()
93- case <-retry:
94- case <-repeat:
95- if adapter := s.Window.InputAdapter(); adapter != nil {
96- if input, ok := adapter.Tick(time.Now()); ok {
97- sched.Invalidate()
98- if app.Input != nil {
99- if err := app.Input([]wayland.Input{input}); err != nil {
100- return err
101- }
102- }
103- }
104- }
105- case ev := <-wlEvents:
106- if ev.Kind == wayland.BufferRelease {
107- if slot := s.Slots[ev.BufferID]; slot != nil {
108- if err := s.Pool.Release(slot.Buffer); err != nil {
109- return err
110- }
111- s.traceState(slot.Buffer, "wl_buffer.release")
112- }
113- } else if err := s.Window.Apply(ev); err != nil {
114- return err
115- }
116- if app.Event != nil {
117- if err := app.Event(ev); err != nil {
118- return err
119- }
120- }
121- // DrainInput is handled at the start of the next iteration.
122- case ev := <-s.releases:
123- if err := s.release(ev); err != nil {
124- return err
125- }
126- }
127- }
128- if s.Device != nil && s.Device.Validation != nil && len(s.Device.Validation.Messages()) > 0 {
129- return fmt.Errorf("%d Vulkan validation errors: %v", len(s.Device.Validation.Messages()), s.Device.Validation.Messages())
130- }
131- return nil
132-}
D internal/presentation/session/close_test.go +0 -23
@@ -1,23 +0,0 @@
1-//go:build linux
2-
3-package session
4-
5-import (
6- "path/filepath"
7- "strings"
8- "testing"
9-)
10-
11-// A requested debug artifact that cannot be written must fail Close, so Run
12-// cannot report success with missing evidence.
13-func TestCloseReportsDebugSummaryWriteError(t *testing.T) {
14- s := &Session{debugDir: filepath.Join(t.TempDir(), "missing")}
15- err := s.Close()
16- if err == nil || !strings.Contains(err.Error(), "resource summary") {
17- t.Fatalf("Close error = %v, want resource summary write error", err)
18- }
19- ok := &Session{debugDir: t.TempDir()}
20- if err := ok.Close(); err != nil {
21- t.Fatalf("Close with writable debug dir: %v", err)
22- }
23-}
D internal/presentation/session/pump.go +0 -93
@@ -1,93 +0,0 @@
1-//go:build linux
2-
3-package session
4-
5-import (
6- "context"
7- "errors"
8-
9- "github.com/bnema/nefergui/internal/render"
10-)
11-
12-// Pump drives one Session from an owner loop that multiplexes several sessions
13-// on a shared Connection. It never reads Wayland events itself: the owner
14-// applies them and forwards BufferRelease. All methods run on the owner loop.
15-type Pump struct {
16- s *Session
17- sched *frameSchedule
18- draw func() (render.Frame, bool, error)
19-}
20-
21-// NewPump prepares s for a shared owner loop. wake is pinged (never blocked on)
22-// when a GPU release point is ready; the owner then calls Step. draw follows
23-// the AppHooks.Draw contract.
24-func (s *Session) NewPump(ctx context.Context, wake chan<- struct{}, draw func() (render.Frame, bool, error)) (*Pump, error) {
25- if draw == nil {
26- return nil, errors.New("session: nil draw")
27- }
28- if s.waitCancel != nil {
29- return nil, errors.New("session already running")
30- }
31- s.waitCtx, s.waitCancel = context.WithCancel(ctx)
32- s.releases = make(chan releaseEvent, 128)
33- s.wake = wake
34- return &Pump{s: s, sched: newFrameSchedule(), draw: draw}, nil
35-}
36-
37-// Session returns the pumped session.
38-func (p *Pump) Session() *Session { return p.s }
39-
40-// Invalidate requests a fresh draw at the next Step.
41-func (p *Pump) Invalidate() { p.sched.Invalidate() }
42-
43-// BufferReleased returns a compositor-released wl_buffer to the pool.
44-func (p *Pump) BufferReleased(id uint64) error {
45- if slot := p.s.Slots[id]; slot != nil {
46- if err := p.s.Pool.Release(slot.Buffer); err != nil {
47- return err
48- }
49- p.s.traceState(slot.Buffer, "wl_buffer.release")
50- }
51- return nil
52-}
53-
54-// Step applies queued GPU releases and submits at most one frame. retry
55-// reports that the owner must call Step again after retryInterval.
56-func (p *Pump) Step() (retry bool, err error) {
57- s := p.s
58- for done := false; !done; {
59- select {
60- case ev := <-s.releases:
61- if err = s.release(ev); err != nil {
62- return false, err
63- }
64- default:
65- done = true
66- }
67- }
68- pending, err := s.finishPending()
69- if err != nil {
70- return false, err
71- }
72- if s.Window.Dirty {
73- p.sched.Invalidate()
74- }
75- committed, err := p.sched.Step(s.Window.Ready(), p.draw, s.tickFrame)
76- if err != nil {
77- return false, err
78- }
79- if committed {
80- return true, nil // the compositor will call back; step again soon for a follow-up
81- }
82- return p.sched.NeedsRetry(pending || s.framePending, s.Window.Ready()), nil
83-}
84-
85-// Close stops release waiters. Call before Session.Close.
86-func (p *Pump) Close() {
87- s := p.s
88- if s.waitCancel != nil {
89- s.waitCancel()
90- s.waiters.Wait()
91- s.waitCancel = nil
92- }
93-}
D internal/presentation/session/readback_test.go +0 -75
@@ -1,75 +0,0 @@
1-//go:build linux
2-
3-package session
4-
5-import (
6- "image"
7- "path/filepath"
8- "testing"
9- "time"
10-)
11-
12-func TestReadbackBudgetAndBoundedWorker(t *testing.T) {
13- s := &Session{readbacks: make(chan readbackJob, readbackQueueSize)}
14- for i := 0; i < maxDebugPNGs; i++ {
15- if !s.wantReadback() {
16- t.Fatalf("budget ended at %d", i)
17- }
18- s.debugFrames++
19- }
20- if s.wantReadback() {
21- t.Fatal("readback copy scheduled after limit")
22- }
23- queue := make(chan readbackJob, readbackQueueSize)
24- for i := 0; i < readbackQueueSize; i++ {
25- if !offerReadback(queue, readbackJob{uint64(i), image.NewNRGBA(image.Rect(0, 0, 1, 1))}) {
26- t.Fatal("queue rejected before full")
27- }
28- }
29- done := make(chan bool, 1)
30- go func() { done <- offerReadback(queue, readbackJob{}) }()
31- select {
32- case accepted := <-done:
33- if accepted {
34- t.Fatal("full queue accepted job")
35- }
36- case <-time.After(time.Second):
37- t.Fatal("full queue blocked owner")
38- }
39- if len(queue) != readbackQueueSize {
40- t.Fatalf("queue grew: %d", len(queue))
41- }
42-}
43-
44-func TestReadbackWorkerCountsWriteErrors(t *testing.T) {
45- s := &Session{
46- debugDir: filepath.Join(t.TempDir(), "missing"),
47- readbacks: make(chan readbackJob, readbackQueueSize),
48- readbackDone: make(chan struct{}),
49- debugWritten: make(chan uint64, maxDebugPNGs),
50- }
51- go s.readbackWorker()
52- s.readbacks <- readbackJob{1, image.NewNRGBA(image.Rect(0, 0, 1, 1))}
53- close(s.readbacks)
54- <-s.readbackDone
55- if s.debugWriteErrors != 1 || s.debugLastWriteErr == "" || len(s.debugWritten) != 0 {
56- t.Fatalf("errors=%d last=%q written=%d", s.debugWriteErrors, s.debugLastWriteErr, len(s.debugWritten))
57- }
58-}
59-
60-// saveReadback is called only after beginReady/retire see Ready(); it has no
61-// Frame.Wait path, even if a PNG is pending. The budget prevents new copies.
62-func TestNoReadbackAfterLimit(t *testing.T) {
63- s := &Session{readbacks: make(chan readbackJob, readbackQueueSize), debugFrames: maxDebugPNGs}
64- if s.wantReadback() {
65- t.Fatal("readback enabled at limit")
66- }
67- s.debugFrames = 0
68- s.Slots = map[uint64]*Slot{1: {ReadbackPending: true}, 2: {ReadbackPending: true}}
69- for i := uint64(0); i < maxDebugPNGs-2; i++ {
70- s.debugFrames++
71- }
72- if s.wantReadback() {
73- t.Fatal("pending readbacks ignored")
74- }
75-}
D internal/presentation/session/schedule.go +0 -78
@@ -1,78 +0,0 @@
1-//go:build linux
2-
3-package session
4-
5-import (
6- "time"
7-
8- "github.com/bnema/nefergui/internal/render"
9-)
10-
11-// retryInterval bounds how long the owner loop sleeps while a frame is blocked
12-// on GPU or buffer readiness that has no event source of its own.
13-const retryInterval = 5 * time.Millisecond
14-
15-// frameSchedule is RunApp's frame state, touched only from the owner loop.
16-// A frame is wanted (dirty) until the compositor accepted a commit or the app
17-// reported no change. A prepared display list is kept across blocked submits so
18-// a retry does not call Draw again. Input, a wake, resize or scale changes ask
19-// Draw for a replacement; an unchanged draw retains the unsubmitted frame.
20-type frameSchedule struct {
21- dirty bool
22- prepared render.Frame
23- have bool
24- stale bool
25-}
26-
27-// newFrameSchedule starts dirty so the first configured frame is drawn.
28-func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} }
29-
30-// Invalidate asks Draw to refresh the UI before submitting. Keep any held
31-// frame until Draw actually replaces it: no-op input must not lose a frame.
32-func (f *frameSchedule) Invalidate() {
33- f.dirty = true
34- f.stale = true
35-}
36-
37-// Step attempts one frame. Nothing is drawn unless a frame is wanted and the
38-// compositor allows one (ready). Draw runs when no frame is held or it is
39-// stale; an unchanged draw keeps a held frame, or goes idle without one.
40-// submit reports whether the frame was
41-// committed: on false the prepared frame is kept for the next attempt, on true
42-// the schedule is clean until the next Invalidate.
43-func (f *frameSchedule) Step(
44- ready bool,
45- draw func() (render.Frame, bool, error),
46- submit func(render.Frame) (bool, error),
47-) (committed bool, err error) {
48- if !f.dirty || !ready {
49- return false, nil
50- }
51- if !f.have || f.stale {
52- frame, changed, err := draw()
53- if err != nil {
54- return false, err
55- }
56- f.stale = false
57- if changed {
58- f.prepared, f.have = frame, true
59- } else if !f.have {
60- f.dirty = false
61- return false, nil
62- }
63- }
64- committed, err = submit(f.prepared)
65- if err != nil || !committed {
66- return false, err
67- }
68- f.dirty, f.have, f.stale, f.prepared = false, false, false, render.Frame{}
69- return true, nil
70-}
71-
72-// NeedsRetry reports whether the loop must wake itself after retryInterval.
73-// gpuPending covers work whose completion only polling can observe. A frame
74-// still to be drawn, or one waiting for a frame callback, is woken by events
75-// instead, so an idle or compositor-throttled loop never spins.
76-func (f *frameSchedule) NeedsRetry(gpuPending, ready bool) bool {
77- return gpuPending || (f.dirty && f.have && ready)
78-}
D internal/presentation/session/schedule_test.go +0 -154
@@ -1,154 +0,0 @@
1-//go:build linux
2-
3-package session
4-
5-import (
6- "errors"
7- "testing"
8-
9- "github.com/bnema/nefergui/internal/render"
10-)
11-
12-// scheduleHarness plays the app and session sides of frameSchedule.
13-type scheduleHarness struct {
14- t *testing.T
15- s *frameSchedule
16- draws int
17- submits []int // frame id (Quads length) of each submit attempt
18- changed bool
19- blocked bool
20- ready bool
21- drawErr error
22- submitErr error
23-}
24-
25-func newHarness(t *testing.T) *scheduleHarness {
26- return &scheduleHarness{t: t, s: newFrameSchedule(), changed: true, ready: true}
27-}
28-
29-func (h *scheduleHarness) step() bool {
30- h.t.Helper()
31- ok, err := h.s.Step(h.ready, func() (render.Frame, bool, error) {
32- h.draws++
33- return render.Frame{Quads: make([]render.Quad, h.draws)}, h.changed, h.drawErr
34- }, func(f render.Frame) (bool, error) {
35- h.submits = append(h.submits, len(f.Quads))
36- return !h.blocked, h.submitErr
37- })
38- if err != nil && h.drawErr == nil && h.submitErr == nil {
39- h.t.Fatal(err)
40- }
41- return ok
42-}
43-
44-func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
45- h := newHarness(t)
46- h.blocked = true
47- if h.step() || h.draws != 1 || !h.s.NeedsRetry(false, true) {
48- t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))
49- }
50- // Retries resubmit the same prepared frame without redrawing.
51- firstRetry, secondRetry := h.step(), h.step()
52- if firstRetry || secondRetry || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
53- t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)
54- }
55- h.blocked = false
56- if !h.step() || h.draws != 1 || h.s.NeedsRetry(false, true) {
57- t.Fatalf("release did not commit cleanly: draws=%d submits=%v", h.draws, h.submits)
58- }
59- if h.step() || len(h.submits) != 4 {
60- t.Fatalf("clean schedule submitted again: %v", h.submits)
61- }
62-}
63-
64-func TestScheduleInvalidateReplacesChangedFrame(t *testing.T) {
65- h := newHarness(t)
66- h.blocked = true
67- h.step()
68- h.s.Invalidate()
69- h.blocked = false
70- if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 {
71- t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits)
72- }
73-}
74-
75-func TestScheduleNoOpInputKeepsUnsubmittedFrame(t *testing.T) {
76- h := newHarness(t)
77- h.blocked = true
78- h.step()
79- h.s.Invalidate()
80- h.changed = false
81- if h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 {
82- t.Fatalf("no-op lost held frame: draws=%d submits=%v", h.draws, h.submits)
83- }
84- h.blocked = false
85- if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 {
86- t.Fatalf("release lost held frame: draws=%d submits=%v", h.draws, h.submits)
87- }
88- if h.s.NeedsRetry(false, true) {
89- t.Fatal("committed schedule did not go idle")
90- }
91-}
92-
93-func TestScheduleDrawUnchangedGoesIdle(t *testing.T) {
94- h := newHarness(t)
95- h.changed = false
96- if h.step() || h.draws != 1 || len(h.submits) != 0 {
97- t.Fatalf("unchanged draw: draws=%d submits=%v", h.draws, h.submits)
98- }
99- if h.s.NeedsRetry(false, true) {
100- t.Fatal("idle schedule asks for retry (busy loop)")
101- }
102- for i := 0; i < 3; i++ {
103- h.step()
104- }
105- if h.draws != 1 {
106- t.Fatalf("idle loop redrew %d times", h.draws)
107- }
108- h.s.Invalidate()
109- h.changed = true
110- if !h.step() || h.draws != 2 {
111- t.Fatalf("wake after idle: draws=%d", h.draws)
112- }
113-}
114-
115-func TestScheduleWaitsForFrameCallback(t *testing.T) {
116- h := newHarness(t)
117- h.ready = false
118- if h.step() || h.draws != 0 || len(h.submits) != 0 || h.s.NeedsRetry(false, false) {
119- t.Fatalf("drew before frame callback: draws=%d", h.draws)
120- }
121- h.ready = true
122- if !h.step() || h.draws != 1 {
123- t.Fatalf("frame callback did not release draw: draws=%d", h.draws)
124- }
125- // A prepared frame waiting for a callback is woken by the event, not polled.
126- h.s.Invalidate()
127- h.blocked = true
128- h.step()
129- if h.s.NeedsRetry(false, false) || !h.s.NeedsRetry(false, true) {
130- t.Fatal("retry must follow frame readiness")
131- }
132-}
133-
134-func TestSchedulePendingGPUForcesRetry(t *testing.T) {
135- h := newHarness(t)
136- h.changed = false
137- h.step()
138- if !h.s.NeedsRetry(true, false) {
139- t.Fatal("pending GPU work must schedule a bounded retry")
140- }
141-}
142-
143-func TestScheduleErrorsPropagate(t *testing.T) {
144- h := newHarness(t)
145- h.drawErr = errors.New("draw")
146- if _, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, false, h.drawErr }, nil); !errors.Is(err, h.drawErr) {
147- t.Fatalf("draw err = %v", err)
148- }
149- h.drawErr = nil
150- h.submitErr = errors.New("submit")
151- if ok, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, true, nil }, func(render.Frame) (bool, error) { return true, h.submitErr }); ok || !errors.Is(err, h.submitErr) {
152- t.Fatalf("submit error: ok=%v err=%v", ok, err)
153- }
154-}
D internal/presentation/session/session.go +0 -1000
@@ -1,1000 +0,0 @@
1-//go:build linux
2-
3-// Package session coordinates Wayland ownership, Vulkan submissions, DRM
4-// release points and the application frame loop behind nefergui.Run.
5-package session
6-
7-import (
8- "bufio"
9- "context"
10- "encoding/json"
11- "errors"
12- "fmt"
13- "image"
14- "image/png"
15- "os"
16- "path/filepath"
17- "runtime"
18- "sync"
19- "time"
20-
21- "github.com/bnema/nefergui/internal/platform/wayland"
22- "github.com/bnema/nefergui/internal/presentation/buffers"
23- "github.com/bnema/nefergui/internal/presentation/syncobj"
24- "github.com/bnema/nefergui/internal/presentation/vkdevice"
25- "github.com/bnema/nefergui/internal/render"
26- "github.com/bnema/nefergui/internal/text"
27- "github.com/bnema/wlturbo/protocol/core"
28- "github.com/bnema/wlturbo/protocol/drmsyncobj"
29- "golang.org/x/sys/unix"
30-)
31-
32-type Slot struct {
33- Buffer *buffers.Buffer
34- Image *vkdevice.Image
35- Frame *vkdevice.Frame
36- First bool
37- Wait *vkdevice.BinaryFence
38- PendingWait *vkdevice.BinaryFence
39- WaitInFlight bool
40- ReadbackFrame uint64
41- ReadbackPending bool
42- ReleaseHandle uint32
43- ReleaseTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline
44- // WlBuffer is the compositor-side wl_buffer of the Wayland-owning Session.
45- WlBuffer *core.Buffer
46-
47- // Used only by Target, the Wayland-free core. Descriptors are -1 when unset.
48- ReleaseFD int // eventfd armed on each commit's release point
49- ReleaseExport int // exported syncobj fd of the release timeline
50- Presented bool // the caller has been told to import this buffer
51-}
52-type Session struct {
53- Window *wayland.Window
54- Device *vkdevice.Device
55- Node *syncobj.Node
56- Pool *buffers.Pool
57- Pipeline *vkdevice.Pipeline
58- ListPipeline *vkdevice.Pipeline
59- Preparer *render.Preparer
60- list render.ListBuffer // owner-loop scratch; see tickFrame
61- Slots map[uint64]*Slot
62- retiredPipelines []retiredPipelines
63- Acquire uint32
64- AcquireTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline
65- Width, Height int32
66- Transparent bool
67- trace *os.File
68- traceBuffer *bufio.Writer
69- debugDir string
70- timings *os.File
71- timingBuffer *bufio.Writer
72- frame uint64
73- debugFrames uint64
74- debugDropped uint64
75- readbacks chan readbackJob
76- readbackDone chan struct{}
77- debugWritten chan uint64
78- // Written only by readbackWorker; read after readbackDone.
79- debugWriteErrors uint64
80- debugLastWriteErr string
81- fdStart, fdPeak int
82- framePending bool
83- waitCtx context.Context
84- waitCancel context.CancelFunc
85- waiters sync.WaitGroup
86- releases chan releaseEvent
87- wake chan<- struct{} // optional: pinged after a release is queued
88-}
89-
90-type retiredPipelines struct {
91- generation uint64
92- rect, list *vkdevice.Pipeline
93-}
94-
95-type readbackJob struct {
96- frame uint64
97- image *image.NRGBA
98-}
99-
100-const maxDebugPNGs = 16
101-const readbackQueueSize = 2
102-
103-func captureBudget(saved, dropped uint64, pending int, queued int, limit uint64) bool {
104- return saved+dropped+uint64(pending+queued) < limit
105-}
106-
107-// offerReadback never blocks the state owner. The worker owns the copied image.
108-func offerReadback(queue chan readbackJob, job readbackJob) bool {
109- select {
110- case queue <- job:
111- return true
112- default:
113- return false
114- }
115-}
116-
117-// readbackWorker owns debugWriteErrors until readbackDone is closed.
118-func (s *Session) readbackWorker() {
119- defer close(s.readbackDone)
120- for job := range s.readbacks {
121- path := filepath.Join(s.debugDir, fmt.Sprintf("readback-%06d.png", job.frame))
122- vkdevice.SwizzleBGRA(job.image)
123- if err := writePNG(path, job.image); err != nil {
124- s.debugWriteErrors++
125- s.debugLastWriteErr = err.Error()
126- continue
127- }
128- s.debugWritten <- job.frame
129- }
130-}
131-
132-func writePNG(path string, img image.Image) error {
133- f, err := os.Create(path)
134- if err != nil {
135- return err
136- }
137- err = png.Encode(f, img)
138- if closeErr := f.Close(); err == nil {
139- err = closeErr
140- }
141- return err
142-}
143-
144-type releaseEvent struct {
145- id, point uint64
146- err error
147-}
148-
149-// traceState is entirely disabled when NEFERGUI_DEBUG_DIR is unset.
150-func (s *Session) traceState(b *buffers.Buffer, event string) {
151- if s.trace == nil || b == nil {
152- return
153- }
154- _ = json.NewEncoder(s.traceBuffer).Encode(struct {
155- Time time.Time `json:"time"`
156- Frame, Buffer, Generation uint64
157- State buffers.State
158- Event string
159- AcquirePoint, ReleasePoint uint64
160- WlReleased, PointSignaled bool
161- }{time.Now(), s.frame, b.ID, b.Generation, b.State, event, b.AcquirePoint, b.ReleasePoint, b.WlReleased, b.PointSignaled})
162-}
163-
164-// Open opens a default xdg-toplevel session.
165-func Open(name string, width, height int32, transparent bool) (*Session, error) {
166- return OpenWithOptions(context.Background(), name, width, height, transparent, wayland.SurfaceOptions{})
167-}
168-
169-// OpenWithOptions opens a session whose surface role and initial input region
170-// are selected by opts; the zero value is identical to Open. Wayland startup
171-// is abandoned when ctx is cancelled; ctx is not retained after it returns.
172-func OpenWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts wayland.SurfaceOptions) (_ *Session, err error) {
173- w, err := wayland.ConnectWithOptions(ctx, name, width, height, transparent, opts)
174- if err != nil {
175- return nil, err
176- }
177- return OpenWindow(w, transparent)
178-}
179-
180-// OpenWindow builds the Vulkan/DMA-BUF/explicit-sync path for a window that is
181-// already configured and has completed dmabuf feedback (a Connection sibling).
182-// On error the window is closed. The session owns w from here on.
183-func OpenWindow(w *wayland.Window, transparent bool) (_ *Session, err error) {
184- if !w.Configured || !w.FeedbackDone {
185- _ = w.Close()
186- return nil, errors.New("session: window is not configured with complete dmabuf feedback")
187- }
188- s := &Session{Window: w, Transparent: transparent, Slots: make(map[uint64]*Slot)}
189- if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" {
190- s.debugDir = dir
191- if err = os.MkdirAll(dir, 0700); err != nil {
192- _ = w.Close()
193- return nil, err
194- }
195- s.trace, err = os.Create(filepath.Join(dir, "buffer-lifecycle.jsonl"))
196- if err != nil {
197- _ = w.Close()
198- return nil, err
199- }
200- s.timings, err = os.Create(filepath.Join(dir, "frame-timings.jsonl"))
201- if err != nil {
202- _ = s.trace.Close()
203- _ = w.Close()
204- return nil, err
205- }
206- s.traceBuffer = bufio.NewWriterSize(s.trace, 64*1024)
207- s.timingBuffer = bufio.NewWriterSize(s.timings, 64*1024)
208- s.readbacks = make(chan readbackJob, readbackQueueSize)
209- s.readbackDone = make(chan struct{})
210- s.debugWritten = make(chan uint64, maxDebugPNGs)
211- go s.readbackWorker()
212- }
213- defer func() {
214- if err != nil {
215- err = errors.Join(err, s.Close())
216- }
217- }()
218- s.Preparer, err = render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages)
219- if err != nil {
220- return nil, err
221- }
222- s.Device, err = vkdevice.Open(vkdevice.DeviceIdentity(w.MainDevice))
223- if err != nil {
224- return nil, err
225- }
226- s.Node, err = syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", s.Device.Render.Minor)))
227- if err != nil {
228- return nil, err
229- }
230- if s.Acquire, err = s.Node.Create(); err != nil {
231- return nil, err
232- }
233- if s.AcquireTimeline, err = s.importTimeline(s.Acquire); err != nil {
234- return nil, err
235- }
236- s.Pool, err = buffers.New(3)
237- if err != nil {
238- return nil, err
239- }
240- if err = s.newGeneration(); err != nil {
241- return nil, err
242- }
243- return s, nil
244-}
245-
246-func (s *Session) importTimeline(handle uint32) (*drmsyncobj.WpLinuxDrmSyncobjTimeline, error) {
247- fd, err := s.Node.ExportTimeline(handle)
248- if err != nil {
249- return nil, err
250- }
251- // WLTurbo closes the sent descriptor on successful ImportTimeline; on
252- // transport failure its ownership remains with this caller.
253- timeline, err := s.Window.SyncManager.ImportTimeline(fd)
254- if err != nil {
255- _ = unix.Close(fd)
256- return nil, err
257- }
258- return timeline, nil
259-}
260-
261-func (s *Session) newGeneration() error {
262- width, height, err := s.Window.PhysicalSize()
263- if err != nil {
264- return err
265- }
266- mods, err := s.Device.ExportableModifiers()
267- if err != nil {
268- return err
269- }
270- var formats []vkdevice.Format
271- for _, tranche := range s.Window.Tranches {
272- for _, entry := range tranche {
273- formats = append(formats, vkdevice.Format{FourCC: entry.Format, Modifier: entry.Modifier})
274- }
275- }
276- selected, err := vkdevice.ChooseModifier(formats, s.Transparent, mods)
277- if err != nil {
278- return err
279- }
280- pipeline, err := s.Device.NewRectPipeline(width, height)
281- if err != nil {
282- return err
283- }
284- listPipeline, err := s.Device.NewListPipeline(width, height)
285- if err != nil {
286- pipeline.Close()
287- return err
288- }
289- // A pipeline may still be referenced by submissions for the previous
290- // generation. retire closes it only after all old slots have retired.
291- if s.Pipeline != nil {
292- s.retiredPipelines = append(s.retiredPipelines, retiredPipelines{
293- generation: s.Pool.Generation - 1, rect: s.Pipeline, list: s.ListPipeline,
294- })
295- }
296- s.Pipeline = pipeline
297- s.ListPipeline = listPipeline
298- s.Width, s.Height = width, height
299- for _, b := range s.Pool.Buffers {
300- if b.Generation != s.Pool.Generation {
301- continue
302- }
303- img, e := s.Device.AllocateImage(width, height, selected)
304- if e != nil {
305- return e
306- }
307- frame, e := s.Device.NewFrame(img)
308- if e == nil && s.debugDir != "" {
309- frame.Readback, e = s.Device.NewReadback(width, height)
310- }
311- if e != nil {
312- if frame != nil {
313- frame.Close()
314- }
315- img.Close()
316- return e
317- }
318- slot := &Slot{Buffer: b, Image: img, Frame: frame, First: true}
319- if slot.WlBuffer, e = s.importBuffer(img, width, height, selected.DRMFormat); e != nil {
320- frame.Close()
321- img.Close()
322- return e
323- }
324- slot.ReleaseHandle, e = s.Node.Create()
325- if e != nil {
326- slot.closeImage()
327- frame.Close()
328- return e
329- }
330- slot.ReleaseTimeline, e = s.importTimeline(slot.ReleaseHandle)
331- if e != nil {
332- _ = s.Node.Destroy(slot.ReleaseHandle)
333- slot.closeImage()
334- frame.Close()
335- return e
336- }
337- s.Slots[b.ID] = slot
338- slot.WlBuffer.OnRelease(func() { s.Window.BufferReleased(b.ID) })
339- }
340- return nil
341-}
342-
343-// importBuffer wraps the image's DMA-BUF in a wl_buffer. The image keeps its
344-// own descriptor; a duplicate goes to the compositor.
345-func (s *Session) importBuffer(img *vkdevice.Image, width, height int32, format uint32) (*core.Buffer, error) {
346- fd, err := unix.FcntlInt(uintptr(img.FD), unix.F_DUPFD_CLOEXEC, 3)
347- if err != nil {
348- return nil, fmt.Errorf("duplicate DMA-BUF fd: %w", err)
349- }
350- params, err := s.Window.Dmabuf.CreateParams()
351- if err != nil {
352- _ = unix.Close(fd)
353- return nil, err
354- }
355- defer params.Destroy()
356- // WLTurbo consumes fd only when its complete Add request succeeds.
357- if err = params.Add(fd, 0, img.Offset, img.Stride, uint32(img.Modifier>>32), uint32(img.Modifier)); err != nil {
358- _ = unix.Close(fd)
359- return nil, err
360- }
361- return params.CreateImmed(width, height, format, 0)
362-}
363-
364-// closeImage destroys the wl_buffer, then frees the GPU image.
365-func (slot *Slot) closeImage() {
366- if slot.WlBuffer != nil {
367- _ = slot.WlBuffer.Destroy()
368- slot.WlBuffer = nil
369- }
370- slot.Image.Close()
371-}
372-
373-// beginReady does not change Pool ownership until the previous GPU submission
374-// has completed. An unready available buffer is retried on a bounded wakeup.
375-func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {
376- waiting := false
377- for _, b := range pool.Buffers {
378- if b.Generation != pool.Generation || b.State != buffers.Available {
379- continue
380- }
381- slot := slots[b.ID]
382- if slot == nil {
383- return nil, nil, false, fmt.Errorf("missing slot %d", b.ID)
384- }
385- ok, err := ready(slot)
386- if err != nil {
387- return nil, nil, false, err
388- }
389- if !ok {
390- waiting = true
391- continue
392- }
393- b.State = buffers.Rendering
394- b.WlReleased, b.PointSignaled = false, false
395- return b, slot, false, nil
396- }
397- return nil, nil, waiting, nil
398-}
399-
400-// Tick presents at most one frame. The caller drives the Wayland dispatch
401-// loop; a frame callback only schedules Tick, never makes an image reusable.
402-func (s *Session) Tick(rect vkdevice.Rect) (bool, error) {
403- return s.tick(&rect, nil, nil, nil)
404-}
405-
406-// TickList presents ordered instanced quads with the same ownership gates as Tick.
407-func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload) (bool, error) {
408- var batches []vkdevice.Batch
409- if len(instances) > 0 {
410- batches = []vkdevice.Batch{{Count: uint32(len(instances))}}
411- }
412- return s.tick(nil, instances, uploads, batches)
413-}
414-
415-// tickFrame lists and submits one prepared frame on the owner loop. The list
416-// buffer's slices are borrowed only for this call: tick copies instances into
417-// mapped GPU memory and resolve copies batches before returning, and no pending
418-// GPU work or retained frame refers to them (a blocked frame is re-listed from
419-// its render.Frame on retry). Release then drops borrowed image references.
420-func (s *Session) tickFrame(frame render.Frame) (bool, error) {
421- defer s.list.Release()
422- instances, batches, stats := s.list.List(frame)
423- if len(stats.Skipped) != 0 {
424- return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
425- }
426- return s.tick(nil, instances, frame.Uploads, batches)
427-}
428-
429-func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
430- started := time.Now()
431- if s.Window.Dirty {
432- w, h, err := s.Window.PhysicalSize()
433- if err != nil {
434- return false, err
435- }
436- if w != s.Width || h != s.Height {
437- if err = s.Pool.Resize(3); err != nil {
438- return false, err
439- }
440- if err = s.newGeneration(); err != nil {
441- return false, err
442- }
443- }
444- s.Window.Dirty = false
445- }
446- if err := s.retire(); err != nil {
447- return false, err
448- }
449- if !s.Window.FrameReady {
450- return false, nil
451- }
452- b, slot, waiting, err := beginReady(s.Pool, s.Slots, func(slot *Slot) (bool, error) { return slot.Frame.Ready() })
453- if err != nil {
454- return false, err
455- }
456- if b == nil {
457- s.framePending = waiting
458- return false, nil
459- }
460- s.framePending = false
461- s.traceState(b, "begin")
462- if err := s.saveReadback(slot); err != nil {
463- return false, err
464- }
465- if slot.Wait != nil && slot.WaitInFlight {
466- slot.Wait.Close()
467- slot.Wait = nil
468- slot.WaitInFlight = false
469- }
470- capture := s.wantReadback()
471- if rect != nil {
472- err = slot.Frame.Record(slot.Image, s.Pipeline, s.Width, s.Height, *rect, slot.First, capture)
473- } else {
474- err = slot.Frame.RecordList(slot.Image, s.ListPipeline, s.Width, s.Height, instances, uploads, batches, slot.First, capture)
475- }
476- if err != nil {
477- return false, err
478- }
479- if err = slot.Frame.Submit(slot.Wait); err != nil {
480- return false, err
481- }
482- // The render module keeps uploads owed until this acknowledgement.
483- s.Preparer.Submitted(uploads)
484- if slot.Wait != nil {
485- slot.WaitInFlight = true
486- }
487- // Keep the imported release-wait semaphore alive until GPU completion.
488- slot.First = false
489- fd, err := slot.Frame.Signal.Export()
490- if err != nil {
491- return false, err
492- }
493- acquire, err := s.Pool.Acquire(b)
494- if err != nil {
495- _ = unix.Close(fd)
496- return false, err
497- }
498- if err = s.Node.AcquirePoint(fd, s.Acquire, acquire); err != nil {
499- _ = unix.Close(fd)
500- return false, err
501- }
502- s.traceState(b, "acquire_point_exported")
503- release, err := s.Pool.Commit(b)
504- if err != nil {
505- return false, err
506- }
507- if err = s.Window.Present(slot.WlBuffer, s.AcquireTimeline, slot.ReleaseTimeline, acquire, release); err != nil {
508- return false, err
509- }
510- s.traceState(b, "committed")
511- if err = s.Pool.Own(b); err != nil {
512- return false, err
513- }
514- s.traceState(b, "compositor_owns")
515- if capture {
516- slot.ReadbackFrame = s.frame
517- slot.ReadbackPending = true
518- }
519- if s.timings != nil {
520- if len(s.Slots) > 6 {
521- return false, fmt.Errorf("unbounded image generations: %d", len(s.Slots))
522- }
523- fds, _ := os.ReadDir("/proc/self/fd")
524- if len(fds) > s.fdPeak {
525- s.fdPeak = len(fds)
526- }
527- _ = json.NewEncoder(s.timingBuffer).Encode(map[string]any{"frame": s.frame, "buffer": b.ID, "generation": b.Generation, "submit_to_commit_ns": time.Since(started).Nanoseconds(), "fd_count": len(fds), "images": len(s.Slots), "atlas_pages": s.Preparer.Atlas.PagesUsed(), "pipelines": 2 * (1 + len(s.retiredPipelines)), "time": time.Now()})
528- }
529- s.startReleaseWait(slot, release)
530- s.frame++
531- return true, nil
532-}
533-
534-func (s *Session) wantReadback() bool {
535- if s.readbacks == nil {
536- return false
537- }
538- pending := 0
539- for _, slot := range s.Slots {
540- if slot.ReadbackPending {
541- pending++
542- }
543- }
544- return captureBudget(s.debugFrames, s.debugDropped, pending, len(s.readbacks), maxDebugPNGs)
545-}
546-
547-// saveReadback is only called for a completed GPU submission; the state owner
548-// copies into owned memory and hands the PNG encoding to the bounded worker.
549-func (s *Session) saveReadback(slot *Slot) error {
550- if !slot.ReadbackPending || slot.Frame.Readback == nil {
551- return nil
552- }
553- // Do not map a full-size image while the encoder cannot accept it.
554- if len(s.readbacks) == cap(s.readbacks) {
555- slot.ReadbackPending = false
556- s.debugDropped++
557- return nil
558- }
559- img, err := slot.Frame.Readback.Copy()
560- if err != nil {
561- return err
562- }
563- slot.ReadbackPending = false
564- if offerReadback(s.readbacks, readbackJob{slot.ReadbackFrame, img}) {
565- s.debugFrames++
566- } else {
567- s.debugDropped++
568- }
569- return nil
570-}
571-
572-// startReleaseWait never mutates Pool or Vulkan state from the waiter.
573-func (s *Session) startReleaseWait(slot *Slot, point uint64) {
574- if s.waitCancel == nil {
575- return
576- }
577- ctx := s.waitCtx
578- id, handle := slot.Buffer.ID, slot.ReleaseHandle
579- s.waiters.Add(1)
580- go func() {
581- defer s.waiters.Done()
582- for {
583- if ctx.Err() != nil {
584- return
585- }
586- ready, err := s.Node.WaitPoint(handle, point, 20*time.Millisecond)
587- if err != nil || ready {
588- select {
589- case s.releases <- releaseEvent{id: id, point: point, err: err}:
590- if s.wake != nil {
591- select {
592- case s.wake <- struct{}{}:
593- default:
594- }
595- }
596- case <-ctx.Done():
597- }
598- return
599- }
600- }
601- }()
602-}
603-
604-func (s *Session) release(ev releaseEvent) error {
605- if ev.err != nil {
606- return ev.err
607- }
608- slot := s.Slots[ev.id]
609- if slot == nil {
610- return nil
611- }
612- b := slot.Buffer
613- // An older completion cannot authorize a newer commit of the same image.
614- if b.ReleasePoint != ev.point || b.State != buffers.CompositorOwned {
615- return nil
616- }
617- if err := s.Pool.Signal(b, ev.point); err != nil {
618- return err
619- }
620- s.traceState(b, "release_point_signaled")
621- if b.Generation == s.Pool.Generation {
622- fd, err := s.Node.ReleaseFence(slot.ReleaseHandle, ev.point)
623- if err != nil {
624- return err
625- }
626- wait, err := s.Device.NewBinaryFence(false)
627- if err != nil {
628- _ = unix.Close(fd)
629- return err
630- }
631- if err = wait.ImportTemporary(fd); err != nil {
632- _ = unix.Close(fd)
633- wait.Close()
634- return err
635- }
636- installed, e := installReleaseWait(slot, wait, slot.Frame.Ready)
637- if e != nil {
638- wait.Close()
639- return e
640- }
641- if !installed {
642- return nil
643- }
644- if err = s.Pool.Reuse(b, true); err != nil {
645- return err
646- }
647- s.traceState(b, "available")
648- }
649- return s.retire()
650-}
651-
652-// installReleaseWait holds the new semaphore until the old submission is done.
653-// Only the state owner calls it. On an unready fence it leaves Pool Released.
654-func installReleaseWait(slot *Slot, next *vkdevice.BinaryFence, ready func() (bool, error)) (bool, error) {
655- if slot.Wait != nil && slot.WaitInFlight {
656- ok, err := ready()
657- if err != nil {
658- return false, err
659- }
660- if !ok {
661- slot.PendingWait = next
662- return false, nil
663- }
664- }
665- if slot.Wait != nil {
666- slot.Wait.Close()
667- }
668- slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false
669- return true, nil
670-}
671-
672-// finishPending runs only on the state-owner loop; never waits on a GPU fence.
673-func (s *Session) finishPending() (bool, error) {
674- pending := false
675- for _, slot := range s.Slots {
676- if slot.PendingWait == nil {
677- continue
678- }
679- ready, err := slot.Frame.Ready()
680- if err != nil {
681- return false, err
682- }
683- if !ready {
684- pending = true
685- continue
686- }
687- if slot.Wait != nil {
688- slot.Wait.Close()
689- }
690- slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
691- if err := s.Pool.Reuse(slot.Buffer, true); err != nil {
692- return false, err
693- }
694- s.traceState(slot.Buffer, "available")
695- }
696- return pending, nil
697-}
698-
699-func (s *Session) retire() error {
700- for _, slot := range s.Slots {
701- b := slot.Buffer
702- if !s.Pool.Retirable(b) {
703- continue
704- }
705- ready, err := slot.Frame.Ready()
706- if err != nil {
707- return err
708- }
709- if !ready {
710- continue
711- }
712- if err := s.saveReadback(slot); err != nil {
713- return err
714- }
715- slot.Frame.Close()
716- if slot.PendingWait != nil {
717- slot.PendingWait.Close()
718- }
719- if slot.Wait != nil {
720- slot.Wait.Close()
721- }
722- slot.closeImage()
723- _ = slot.ReleaseTimeline.Destroy()
724- _ = s.Node.Destroy(slot.ReleaseHandle)
725- s.traceState(b, "retired")
726- delete(s.Slots, b.ID)
727- if err = s.Pool.Remove(b); err != nil {
728- return err
729- }
730- }
731- for i := 0; i < len(s.retiredPipelines); {
732- old := s.retiredPipelines[i]
733- live := false
734- for _, slot := range s.Slots {
735- if slot.Buffer.Generation == old.generation {
736- live = true
737- break
738- }
739- }
740- if live {
741- i++
742- continue
743- }
744- old.rect.Close()
745- old.list.Close()
746- s.retiredPipelines = append(s.retiredPipelines[:i], s.retiredPipelines[i+1:]...)
747- }
748- return nil
749-}
750-
751-func (s *Session) Run(ctx context.Context, frames int, draw func(int) vkdevice.Rect) (int, error) {
752- return s.RunWithCommit(ctx, frames, draw, nil)
753-}
754-
755-// RunWithCommit calls committed after each successfully committed frame.
756-// The optional hook is for the example's capture request; it never gates reuse.
757-func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int) vkdevice.Rect, committed func(int) error) (int, error) {
758- if draw == nil {
759- return 0, fmt.Errorf("nil draw function")
760- }
761- return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)
762-}
763-
764-// RunPreparedListWithCommit prepares uploads on every attempt. Preparer keeps
765-// them owed until a submission, so a skipped attempt is resent by the next one.
766-func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {
767- if draw == nil {
768- return 0, fmt.Errorf("nil draw function")
769- }
770- return s.runWithCommit(ctx, frames, func(i int) (bool, error) {
771- frame, err := draw(i)
772- if err != nil {
773- return false, err
774- }
775- return s.tickFrame(frame)
776- }, committed)
777-}
778-
779-// RunListWithCommit drives the list renderer using the existing frame loop.
780-func (s *Session) RunListWithCommit(ctx context.Context, frames int, draw func(int) []vkdevice.Instance, committed func(int) error) (int, error) {
781- if draw == nil {
782- return 0, fmt.Errorf("nil draw function")
783- }
784- return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.TickList(draw(i), nil) }, committed)
785-}
786-
787-func (s *Session) runWithCommit(ctx context.Context, frames int, tick func(int) (bool, error), committed func(int) error) (int, error) {
788- if frames < 0 {
789- return 0, fmt.Errorf("invalid frame count")
790- }
791- if s.waitCancel != nil {
792- return 0, fmt.Errorf("session already running")
793- }
794- s.waitCtx, s.waitCancel = context.WithCancel(ctx)
795- s.releases = make(chan releaseEvent, 128)
796- if s.debugDir != "" {
797- fds, _ := os.ReadDir("/proc/self/fd")
798- s.fdStart = len(fds)
799- s.fdPeak = s.fdStart
800- }
801- wlEvents := s.Window.StartReader()
802- count := 0
803- defer func() { s.waitCancel(); s.waiters.Wait() }()
804- for !s.Window.Closed && (frames == 0 || count < frames) {
805- if err := ctx.Err(); err != nil {
806- return count, err
807- }
808- pending, err := s.finishPending()
809- if err != nil {
810- return count, err
811- }
812- if ok, err := tick(count); err != nil {
813- return count, err
814- } else if ok {
815- count++
816- if committed != nil {
817- if err := committed(count); err != nil {
818- return count, err
819- }
820- }
821- continue
822- }
823- var retry <-chan time.Time
824- if pending || s.framePending {
825- retry = time.After(5 * time.Millisecond)
826- }
827- select {
828- case <-ctx.Done():
829- return count, ctx.Err()
830- case <-retry:
831- case ev := <-wlEvents:
832- if ev.Kind == wayland.BufferRelease {
833- if slot := s.Slots[ev.BufferID]; slot != nil {
834- if err := s.Pool.Release(slot.Buffer); err != nil {
835- return count, err
836- }
837- s.traceState(slot.Buffer, "wl_buffer.release")
838- }
839- } else if err := s.Window.Apply(ev); err != nil {
840- return count, err
841- }
842- case ev := <-s.releases:
843- if err := s.release(ev); err != nil {
844- return count, err
845- }
846- }
847- }
848- if s.Device != nil && s.Device.Validation != nil && len(s.Device.Validation.Messages()) > 0 {
849- return count, fmt.Errorf("%d Vulkan validation errors: %v", len(s.Device.Validation.Messages()), s.Device.Validation.Messages())
850- }
851- return count, nil
852-}
853-
854-// Close detaches and destroys the explicit-sync surface, traces release points
855-// for at most 200 ms, waits for our GPU, then frees every client allocation.
856-// Compositor imports keep independent DMA-BUF kernel references.
857-// It returns errors from writing requested debug artifacts; allocations are
858-// freed regardless.
859-func (s *Session) Close() (err error) {
860- if s == nil {
861- return nil
862- }
863- if s.trace != nil {
864- defer func() { err = errors.Join(err, s.traceBuffer.Flush(), s.trace.Close()) }()
865- }
866- if s.timings != nil {
867- defer func() { err = errors.Join(err, s.timingBuffer.Flush(), s.timings.Close()) }()
868- }
869- if s.readbacks != nil {
870- defer func() {
871- if s.readbacks != nil {
872- close(s.readbacks)
873- <-s.readbackDone
874- }
875- }()
876- }
877- if s.Window != nil && s.Window.Surface != nil {
878- // A null-buffer commit is a protocol error on an ext-session-lock
879- // surface; its destruction hides it instead.
880- if !s.Window.IsLock() {
881- _ = s.Window.Surface.Attach(nil, 0, 0)
882- _ = s.Window.Surface.Commit()
883- }
884- _ = s.Window.DestroySyncSurface()
885- }
886- // Drain detach/release points for tracing, not for freeing allocations. The
887- // reader and waiters continue posting events, but the loop owns all state.
888- if s.waitCancel != nil && s.Pool != nil {
889- deadline := time.NewTimer(200 * time.Millisecond)
890- defer deadline.Stop()
891- for {
892- busy := false
893- for _, slot := range s.Slots {
894- if slot.Buffer.State != buffers.Available && slot.Buffer.State != buffers.Released {
895- busy = true
896- break
897- }
898- }
899- if !busy {
900- break
901- }
902- select {
903- case ev := <-s.releases:
904- _ = s.release(ev)
905- case ev := <-s.Window.ReaderEvents(): // nil on a shared connection
906- if ev.Kind == wayland.BufferRelease {
907- if slot := s.Slots[ev.BufferID]; slot != nil {
908- _ = s.Pool.Release(slot.Buffer)
909- }
910- } else if ev.Kind != wayland.TransportError {
911- _ = s.Window.Apply(ev)
912- }
913- case <-deadline.C:
914- goto stopWaiters
915- }
916- }
917- }
918-stopWaiters:
919- if s.waitCancel != nil {
920- s.waitCancel()
921- s.waiters.Wait()
922- s.waitCancel = nil
923- }
924- // The client GPU's completion is independent from compositor release.
925- if s.Device != nil && s.Device.Logical != 0 && s.Device.Dispatch != nil {
926- _ = s.Device.Dispatch.DeviceWaitIdle(s.Device.Logical)
927- }
928- if s.Pool != nil {
929- _ = s.retire()
930- }
931- for _, slot := range s.Slots {
932- // GPU work must finish before destroying a semaphore it waits on.
933- if slot.ReadbackPending {
934- _ = s.saveReadback(slot)
935- }
936- if slot.Frame != nil {
937- slot.Frame.Close()
938- }
939- if slot.Wait != nil {
940- slot.Wait.Close()
941- }
942- if slot.PendingWait != nil {
943- slot.PendingWait.Close()
944- }
945- if slot.ReleaseTimeline != nil {
946- _ = slot.ReleaseTimeline.Destroy()
947- }
948- // The exported DMA-BUF remains alive in the compositor independently.
949- if slot.Image != nil {
950- slot.closeImage()
951- }
952- if s.Node != nil && slot.ReleaseHandle != 0 {
953- _ = s.Node.Destroy(slot.ReleaseHandle)
954- }
955- }
956- if s.AcquireTimeline != nil {
957- _ = s.AcquireTimeline.Destroy()
958- }
959- if s.Window != nil {
960- _ = s.Window.Close()
961- }
962- if s.Node != nil {
963- if s.Acquire != 0 {
964- _ = s.Node.Destroy(s.Acquire)
965- }
966- _ = s.Node.Close()
967- }
968- for _, old := range s.retiredPipelines {
969- old.rect.Close()
970- old.list.Close()
971- }
972- s.retiredPipelines = nil
973- if s.Pipeline != nil {
974- s.Pipeline.Close()
975- }
976- if s.ListPipeline != nil {
977- s.ListPipeline.Close()
978- }
979- if s.Device != nil {
980- s.Device.Close()
981- }
982- if s.debugDir != "" {
983- // Wait for the worker before reporting completed PNGs.
984- if s.readbacks != nil {
985- close(s.readbacks)
986- <-s.readbackDone
987- s.readbacks = nil
988- }
989- runtime.GC()
990- fds, _ := os.ReadDir("/proc/self/fd")
991- data, e := json.MarshalIndent(map[string]any{"fd_start": s.fdStart, "fd_peak": s.fdPeak, "fd_after": len(fds), "frames": s.frame, "images_after": 0, "debug_png_count": len(s.debugWritten), "debug_png_dropped": s.debugDropped, "debug_png_write_errors": s.debugWriteErrors, "debug_png_last_error": s.debugLastWriteErr}, "", " ")
992- if e == nil {
993- e = os.WriteFile(filepath.Join(s.debugDir, "resource-summary.json"), append(data, '\n'), 0600)
994- }
995- if e != nil {
996- err = errors.Join(err, fmt.Errorf("session: resource summary: %w", e))
997- }
998- }
999- return err
1000-}
A internal/presentation/session/slot.go +81 -0
@@ -0,0 +1,81 @@
1+//go:build linux
2+
3+// Package session is the Wayland-free presentation core: Vulkan rendering into
4+// DMA-BUF buffers, DRM syncobj timelines and buffer ownership.
5+package session
6+
7+import (
8+ "fmt"
9+
10+ "github.com/bnema/nefergui/internal/presentation/buffers"
11+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
12+)
13+
14+// Slot is the GPU side of one pool buffer. Descriptors are -1 when unset.
15+type Slot struct {
16+ Buffer *buffers.Buffer
17+ Image *vkdevice.Image
18+ Frame *vkdevice.Frame
19+ First bool
20+ Wait *vkdevice.BinaryFence
21+ PendingWait *vkdevice.BinaryFence
22+ WaitInFlight bool
23+ ReleaseHandle uint32
24+ ReleaseFD int // eventfd armed on each commit's release point
25+ ReleaseExport int // exported syncobj fd of the release timeline
26+ Presented bool // the caller has been told to import this buffer
27+}
28+
29+func (slot *Slot) closeImage() { slot.Image.Close() }
30+
31+// beginReady does not change Pool ownership until the previous GPU submission
32+// has completed. An unready available buffer is retried on a bounded wakeup.
33+func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {
34+ waiting := false
35+ for _, b := range pool.Buffers {
36+ if b.Generation != pool.Generation || b.State != buffers.Available {
37+ continue
38+ }
39+ slot := slots[b.ID]
40+ if slot == nil {
41+ return nil, nil, false, fmt.Errorf("missing slot %d", b.ID)
42+ }
43+ ok, err := ready(slot)
44+ if err != nil {
45+ return nil, nil, false, err
46+ }
47+ if !ok {
48+ waiting = true
49+ continue
50+ }
51+ b.State = buffers.Rendering
52+ b.WlReleased, b.PointSignaled = false, false
53+ return b, slot, false, nil
54+ }
55+ return nil, nil, waiting, nil
56+}
57+
58+// installReleaseWait holds the new semaphore until the old submission is done.
59+// Only the state owner calls it. On an unready fence it leaves Pool Released.
60+func installReleaseWait(slot *Slot, next *vkdevice.BinaryFence, ready func() (bool, error)) (bool, error) {
61+ if slot.Wait != nil && slot.WaitInFlight {
62+ ok, err := ready()
63+ if err != nil {
64+ return false, err
65+ }
66+ if !ok {
67+ slot.PendingWait = next
68+ return false, nil
69+ }
70+ }
71+ if slot.Wait != nil {
72+ slot.Wait.Close()
73+ }
74+ slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false
75+ return true, nil
76+}
77+
78+type retiredPipelines struct {
79+ generation uint64
80+ list *vkdevice.Pipeline
81+}
D internal/presentation/session/tickframe_test.go +0 -33
@@ -1,33 +0,0 @@
1-//go:build linux
2-
3-package session
4-
5-import (
6- "image"
7- "runtime"
8- "strings"
9- "testing"
10- "weak"
11-
12- "github.com/bnema/nefergui/internal/render"
13-)
14-
15-// An unsupported quad is rejected before any GPU work, and the borrowed list
16-// buffer is released so it never pins the frame's image.
17-func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) {
18- s := &Session{}
19- ref := func() weak.Pointer[image.RGBA] {
20- img := image.NewRGBA(image.Rect(0, 0, 1, 1))
21- ref := weak.Make(img)
22- _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}})
23- if err == nil || !strings.Contains(err.Error(), "unsupported list operations") {
24- t.Fatalf("err=%v", err)
25- }
26- return ref
27- }()
28- runtime.GC()
29- if ref.Value() != nil {
30- t.Fatal("idle list buffer retains the frame image")
31- }
32- runtime.KeepAlive(s)
33-}
D internal/presentation/systemtest/demo_discard_test.go +0 -51
@@ -1,51 +0,0 @@
1-//go:build linux
2-
3-package systemtest
4-
5-import (
6- "encoding/json"
7- "os"
8- "path/filepath"
9- "strings"
10- "testing"
11- "time"
12-)
13-
14-func TestDemoDiscardProtection(t *testing.T) {
15- requireHarness(t)
16- bin := demoBinary(t)
17- for _, tc := range []struct {
18- name, input string
19- empty, pending bool
20- }{
21- {"armed", "move 140 24\nclick left\n", false, true},
22- {"cancel", "move 140 24\nclick left\nsleep 300ms\nmove 275 24\nclick left\n", false, false},
23- {"confirmed", "move 140 24\nclick left\nsleep 300ms\nmove 140 24\nclick left\n", true, false},
24- {"edited", "move 140 24\nclick left\nsleep 300ms\nmove 300 120\nclick left\ntype Added\n", false, false},
25- } {
26- t.Run(tc.name, func(t *testing.T) {
27- script := filepath.Join(t.TempDir(), "input.txt")
28- if err := os.WriteFile(script, []byte("sleep 8s\n"+tc.input+"sleep 1s\n"), 0600); err != nil {
29- t.Fatal(err)
30- }
31- res := runDemo(t, bin, 1, 400, script, 11*time.Second)
32- data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
33- if err != nil {
34- t.Fatal(err)
35- }
36- var state struct {
37- Notes string
38- NewPending bool
39- }
40- if err := json.Unmarshal(data, &state); err != nil {
41- t.Fatal(err)
42- }
43- if (state.Notes == "") != tc.empty || state.NewPending != tc.pending {
44- t.Fatalf("discard state: %+v", state)
45- }
46- if tc.name == "edited" && !strings.Contains(state.Notes, "Added") {
47- t.Fatal("editor input not delivered")
48- }
49- })
50- }
51-}
D internal/presentation/systemtest/demo_test.go +0 -258
@@ -1,258 +0,0 @@
1-//go:build linux
2-
3-package systemtest
4-
5-import (
6- "context"
7- "encoding/json"
8- "fmt"
9- "image"
10- "image/color"
11- "os"
12- "os/exec"
13- "path/filepath"
14- "strings"
15- "testing"
16- "time"
17-
18- "github.com/bnema/nefergui/internal/harness"
19-)
20-
21-func demoBinary(t *testing.T) string {
22- t.Helper()
23- bin := filepath.Join(t.TempDir(), "demo")
24- cmd := exec.Command("go", "build", "-o", bin, "./examples/demo")
25- cmd.Dir = "../../.."
26- cmd.Env = append(os.Environ(), "GOWORK=off", "CGO_ENABLED=0")
27- if out, err := cmd.CombinedOutput(); err != nil {
28- t.Fatalf("build demo: %v %s", err, out)
29- }
30- return bin
31-}
32-func runDemo(t *testing.T, bin string, scale, frames int, input string, minDuration time.Duration) harness.Result {
33- t.Helper()
34- root := filepath.Join(t.TempDir(), "artifacts")
35- client := []string{bin, "--static", "--frames", fmt.Sprint(frames), "--min-duration", minDuration.String()}
36- res, code := harness.Run(context.Background(), harness.Options{Size: fmt.Sprintf("%dx%d", 960*scale, 640*scale), Scale: fmt.Sprint(scale), Layout: "us", Background: background, Out: root, Input: input, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: client})
37- t.Logf("scale=%d frames=%d code=%d artifacts=%s", scale, frames, code, root)
38- if code != harness.Pass {
39- b, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
40- t.Fatalf("harness: %s stderr=%s", res.Error, b)
41- }
42- return res
43-}
44-func TestDemoStatic(t *testing.T) {
45- requireHarness(t)
46- bin := demoBinary(t)
47- for _, scale := range []int{1, 2} {
48- t.Run(fmt.Sprint(scale), func(t *testing.T) {
49- res := runDemo(t, bin, scale, 24, "", 0)
50- files, err := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
51- if err != nil || len(files) == 0 {
52- t.Fatalf("readback files: %v %v", files, err)
53- }
54- img, err := harness.LoadPNG(files[len(files)-1])
55- if err != nil {
56- t.Fatal(err)
57- }
58- if img.Bounds().Dx() != 960*scale || img.Bounds().Dy() != 640*scale {
59- t.Fatalf("size %v", img.Bounds())
60- }
61- for _, p := range []struct {
62- name string
63- x, y int
64- want color.NRGBA
65- }{
66- {"background", 5, 5, color.NRGBA{255, 255, 255, 255}},
67- {"button", 740, 185, color.NRGBA{212, 212, 216, 255}},
68- } {
69- x, y := p.x*scale, p.y*scale
70- t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y)))
71- if err := harness.PixelProbe(img, x, y, p.want, 8); err != nil {
72- t.Error(err)
73- }
74- }
75- // Labels must reach the screen. Each region lies inside one control
76- // fill: most pixels must match it (the control is where expected) and
77- // enough must differ (glyph ink). Background probes alone once passed
78- // with every control label missing.
79- for _, l := range []struct {
80- name string
81- x0, y0, x1, y1 int
82- fill color.NRGBA
83- }{
84- {"Apply", 810, 177, 866, 198, color.NRGBA{212, 212, 216, 255}},
85- {"Untitled.txt", 24, 113, 115, 136, color.NRGBA{228, 228, 231, 255}},
86- {"Dark mode", 850, 14, 935, 35, color.NRGBA{255, 255, 255, 255}},
87- {"Name placeholder", 749, 130, 840, 152, color.NRGBA{250, 250, 250, 255}},
88- } {
89- n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill)
90- if n < 40*scale*scale || n > total/2 {
91- t.Errorf("%s: %d/%d ink pixels, label not painted in its control", l.name, n, total)
92- }
93- }
94- })
95- }
96-}
97-
98-// ink counts pixels in [x0,x1)×[y0,y1) far from fill, and the region size.
99-func ink(img image.Image, x0, y0, x1, y1 int, fill color.NRGBA) (n, total int) {
100- for y := y0; y < y1; y++ {
101- for x := x0; x < x1; x++ {
102- c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
103- if absDiff(c.R, fill.R)+absDiff(c.G, fill.G)+absDiff(c.B, fill.B) > 30 {
104- n++
105- }
106- total++
107- }
108- }
109- return n, total
110-}
111-func absDiff(a, b uint8) int {
112- if a > b {
113- return int(a - b)
114- }
115- return int(b - a)
116-}
117-
118-// TestDemoCursorShapes hovers a menu button, the input and the background,
119-// then reads the cursor shapes NeferWL applied from its run log (the harness
120-// enables wayland debug logging). The shapes come from the demo's CSS: UA
121-// `cursor: pointer` on buttons and `cursor: text` on inputs.
122-func TestDemoCursorShapes(t *testing.T) {
123- requireHarness(t)
124- bin := demoBinary(t)
125- script := filepath.Join(t.TempDir(), "input.txt")
126- // Same cold-start allowance as TestDemoInputAndResources: the script
127- // starts at compositor launch, not when the demo maps.
128- if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 123\nsleep 1s\nmove 800 140\nsleep 1s\nmove 100 580\nsleep 1s\n"), 0600); err != nil {
129- t.Fatal(err)
130- }
131- res := runDemo(t, bin, 1, 400, script, 12*time.Second)
132- data, err := os.ReadFile(res.Artifacts["compositor_run_log"])
133- if err != nil {
134- t.Fatal(err)
135- }
136- var shapes []string
137- for _, line := range strings.Split(string(data), "\n") {
138- var entry struct{ Message, Shape string }
139- if json.Unmarshal([]byte(line), &entry) == nil && entry.Message == "cursor" && entry.Shape != "" {
140- if len(shapes) == 0 || shapes[len(shapes)-1] != entry.Shape {
141- shapes = append(shapes, entry.Shape)
142- }
143- }
144- }
145- if got := strings.Join(shapes, ","); got != "pointer,text,default" {
146- t.Fatalf("cursor shapes %q, want pointer,text,default", got)
147- }
148-}
149-
150-func TestDemoInputAndResources(t *testing.T) {
151- requireHarness(t)
152- bin := demoBinary(t)
153- script := filepath.Join(t.TempDir(), "input.txt")
154- // The compositor starts the script at launch, not when the client maps;
155- // wait past cold start (font discovery) before injecting input. The demo
156- // keeps presenting for 12 s so the run outlasts the ~10 s script however
157- // fast frames render.
158- if err := os.WriteFile(script, []byte("sleep 8s\nmove 800 140\nsleep 200ms\nclick left\nsleep 200ms\ntype Example\nsleep 200ms\nmove 820 186\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil {
159- t.Fatal(err)
160- }
161- res := runDemo(t, bin, 1, 400, script, 12*time.Second)
162- data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
163- if err != nil {
164- t.Fatal(err)
165- }
166- var state struct{ Name, Status string }
167- if err = json.Unmarshal(data, &state); err != nil {
168- t.Fatal(err)
169- }
170- if state.Name != "Example" || state.Status != "Author set to Example · session only" {
171- layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json"))
172- t.Fatalf("state %+v layout=%s", state, layout)
173- }
174- var s summary
175- data, err = os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
176- if err != nil {
177- t.Fatal(err)
178- }
179- if err = json.Unmarshal(data, &s); err != nil {
180- t.Fatal(err)
181- }
182- t.Logf("frames=%d fd_start=%d fd_peak=%d fd_after=%d images_after=%d", s.Frames, s.FDStart, s.FDPeak, s.FDAfter, s.ImagesAfter)
183- // FDStart > 0 proves the summary fields were actually decoded.
184- if s.Frames < 400 || s.FDStart == 0 || s.ImagesAfter != 0 || s.FDPeak-s.FDStart > 2 || s.FDAfter > s.FDStart {
185- t.Fatalf("resources %+v", s)
186- }
187-}
188-
189-// TestDemoIndicators returns to the editor, selects the second radio and
190-// clicks the slider track, then checks the model and the painted indicators
191-// in the captured frame: the selected radio and the slider fill up to the
192-// thumb use the accent color, the rest of the track does not.
193-func TestDemoIndicators(t *testing.T) {
194- requireHarness(t)
195- bin := demoBinary(t)
196- script := filepath.Join(t.TempDir(), "input.txt")
197- // Same cold-start allowance as TestDemoInputAndResources.
198- if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 166\nsleep 200ms\nclick left\nsleep 800ms\nmove 80 123\nsleep 200ms\nclick left\nsleep 500ms\nmove 800 312\nsleep 200ms\nclick left\nsleep 300ms\nmove 883 377\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil {
199- t.Fatal(err)
200- }
201- res := runDemo(t, bin, 1, 400, script, 13*time.Second)
202- data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
203- if err != nil {
204- t.Fatal(err)
205- }
206- var state struct {
207- Page, Density string
208- Volume float64
209- }
210- if err = json.Unmarshal(data, &state); err != nil {
211- t.Fatal(err)
212- }
213- if state.Page != "home" || state.Density != "compact" || state.Volume != 70 {
214- t.Fatalf("state %+v", state)
215- }
216- img := lastMappedFrame(t, res.Artifacts["frames"])
217- accent := color.NRGBA{82, 82, 91, 255} // light theme accent
218- // Muted paint is the demo text color at 45% alpha over the inspector. Each probe
219- // expects a specific color, so missing paint (plain white) fails too.
220- muted := color.NRGBA{151, 151, 152, 255}
221- for _, p := range []struct {
222- name string
223- x, y int
224- want color.NRGBA
225- }{
226- {"selected radio", 743, 304, accent},
227- {"unselected radio outline", 741, 273, muted},
228- {"slider fill", 790, 377, accent},
229- {"slider rail", 930, 377, muted},
230- } {
231- t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y)))
232- if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil {
233- t.Errorf("%s: %v", p.name, err)
234- }
235- }
236-}
237-
238-// lastMappedFrame is the newest compositor frame still showing the demo: the
239-// capture request fires before scripted input, and the final frames follow
240-// client exit.
241-func lastMappedFrame(t *testing.T, dir string) image.Image {
242- t.Helper()
243- files, err := filepath.Glob(filepath.Join(dir, "frame-*.png"))
244- if err != nil {
245- t.Fatal(err)
246- }
247- for i := len(files) - 1; i >= 0; i-- {
248- img, err := harness.LoadPNG(files[i])
249- if err != nil {
250- continue // possibly still being written
251- }
252- if harness.PixelProbe(img, 5, 5, color.NRGBA{255, 255, 255, 255}, 8) == nil || harness.PixelProbe(img, 5, 5, color.NRGBA{32, 32, 35, 255}, 8) == nil {
253- return img
254- }
255- }
256- t.Fatalf("no mapped demo frame in %s", dir)
257- return nil
258-}
D internal/presentation/systemtest/demo_theme_test.go +0 -47
@@ -1,47 +0,0 @@
1-//go:build linux
2-
3-package systemtest
4-
5-import (
6- "encoding/json"
7- "image/color"
8- "os"
9- "path/filepath"
10- "testing"
11- "time"
12-
13- "github.com/bnema/nefergui/internal/harness"
14-)
15-
16-func TestDemoThemeAndPreferences(t *testing.T) {
17- requireHarness(t)
18- bin := demoBinary(t)
19- script := filepath.Join(t.TempDir(), "input.txt")
20- if err := os.WriteFile(script, []byte("sleep 8s\nmove 890 24\nclick left\nsleep 500ms\nmove 80 166\nclick left\nsleep 500ms\nmove 600 580\nsleep 1s\n"), 0600); err != nil {
21- t.Fatal(err)
22- }
23- res := runDemo(t, bin, 1, 400, script, 12*time.Second)
24- data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
25- if err != nil {
26- t.Fatal(err)
27- }
28- var state struct {
29- Page string
30- DarkMode bool
31- }
32- if err := json.Unmarshal(data, &state); err != nil {
33- t.Fatal(err)
34- }
35- if !state.DarkMode || state.Page != "settings" {
36- t.Fatalf("theme/navigation did not update: %+v", state)
37- }
38- img := lastMappedFrame(t, res.Artifacts["frames"])
39- for _, p := range []struct {
40- x, y int
41- want color.NRGBA
42- }{{600, 80, color.NRGBA{24, 24, 27, 255}}, {240, 230, color.NRGBA{24, 24, 27, 255}}} {
43- if err := harness.PixelProbe(img, p.x, p.y, p.want, 3); err != nil {
44- t.Error(err)
45- }
46- }
47-}
D internal/presentation/systemtest/image_test.go +0 -113
@@ -1,113 +0,0 @@
1-//go:build linux
2-
3-package systemtest
4-
5-import (
6- "context"
7- "encoding/json"
8- "fmt"
9- "image/color"
10- "os"
11- "path/filepath"
12- "regexp"
13- "sort"
14- "testing"
15- "time"
16-
17- "github.com/bnema/nefergui/internal/harness"
18-)
19-
20-func TestListImage(t *testing.T) {
21- requireHarness(t)
22- bin := binary(t)
23- for _, scale := range []int{1, 2} {
24- t.Run(fmt.Sprint(scale), func(t *testing.T) {
25- for _, changing := range []bool{false, true} {
26- name := "static"
27- frames := 24
28- if changing {
29- name = "changing"
30- frames = 120
31- }
32- t.Run(name, func(t *testing.T) {
33- root := filepath.Join(t.TempDir(), "artifacts")
34- args := []string{bin, "--static", "--image", "--scale", fmt.Sprint(scale), "--frames", fmt.Sprint(frames)}
35- if changing {
36- args = append(args, "--changing-image")
37- }
38- res, code := harness.Run(context.Background(), harness.Options{Size: "800x600", Scale: "1", Layout: "us", Background: background, Out: root, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: args})
39- t.Logf("artifacts=%s code=%d status=%s", root, code, res.Status)
40- if code != harness.Pass {
41- data, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
42- t.Fatalf("harness: %s %s", res.Error, data)
43- }
44- files, _ := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
45- sort.Strings(files)
46- if len(files) == 0 {
47- t.Fatal("missing readback")
48- }
49- img, err := harness.LoadPNG(files[len(files)-1])
50- if err != nil {
51- t.Fatal(err)
52- }
53- if !changing {
54- bg := color.NRGBA{17, 17, 17, 255}
55- probes := []struct {
56- name string
57- x, y int
58- want color.NRGBA
59- }{
60- {"red", 108, 108, color.NRGBA{136, 8, 8, 255}},
61- {"green", 148, 108, color.NRGBA{8, 136, 8, 255}},
62- {"blue", 108, 148, color.NRGBA{8, 8, 136, 255}},
63- {"yellow", 148, 148, color.NRGBA{136, 136, 8, 255}},
64- {"round corner", 100, 100, bg},
65- {"between images", 170, 130, bg},
66- {"second image", 220, 120, color.NRGBA{255, 128, 0, 255}},
67- }
68- for _, p := range probes {
69- x, y := p.x*scale, p.y*scale
70- t.Logf("scale=%d %s (%d,%d)=%v want=%v", scale, p.name, x, y, color.NRGBAModel.Convert(img.At(x, y)), p.want)
71- if err := harness.PixelProbe(img, x, y, p.want, 4); err != nil {
72- t.Error(err)
73- }
74- }
75- }
76- if changing {
77- data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
78- if err != nil {
79- t.Fatal(err)
80- }
81- var sum summary
82- if err = json.Unmarshal(data, &sum); err != nil {
83- t.Fatal(err)
84- }
85- t.Logf("resources=%+v", sum)
86- if sum.Frames != 120 || sum.ImagesAfter != 0 || sum.FDPeak-sum.FDStart > 2 || sum.FDAfter > sum.FDStart {
87- t.Errorf("unstable resources: %+v", sum)
88- }
89- rows := jsonLines[timing](t, filepath.Join(res.Artifacts["debug"], "frame-timings.jsonl"))
90- if len(rows) != 120 {
91- t.Errorf("frame timings=%d", len(rows))
92- }
93- for _, row := range rows {
94- if row.Images < 1 || row.Images > 6 || row.FDCount > sum.FDStart+2 {
95- t.Errorf("unstable frame: %+v", row)
96- break
97- }
98- }
99- }
100- if os.Getenv("NEFERGUI_VULKAN_VALIDATION") == "1" {
101- data, err := os.ReadFile(res.Artifacts["client_stderr"])
102- if err != nil {
103- t.Fatal(err)
104- }
105- if regexp.MustCompile(`(?i)(NEFERGUI_VULKAN_VALIDATION_(ERROR|WARNING)|validation (error|warning))`).Match(data) {
106- t.Errorf("validation: %s", data)
107- }
108- }
109- })
110- }
111- })
112- }
113-}
D internal/presentation/systemtest/system_test.go +0 -421
@@ -1,421 +0,0 @@
1-//go:build linux
2-
3-package systemtest
4-
5-import (
6- "bufio"
7- "context"
8- "encoding/json"
9- "fmt"
10- "image"
11- "image/color"
12- "math"
13- "os"
14- "os/exec"
15- "path/filepath"
16- "regexp"
17- "sort"
18- "strings"
19- "testing"
20- "time"
21-
22- "github.com/bnema/nefergui/internal/harness"
23-)
24-
25-const background = "#111111"
26-
27-var opaque = color.NRGBA{255, 128, 0, 255}
28-var transparent = color.NRGBA{128, 64, 0, 128}
29-
30-func requireHarness(t *testing.T) {
31- t.Helper()
32- if os.Getenv("NEFERGUI_HARNESS") != "1" {
33- t.Skip("set NEFERGUI_HARNESS=1 to run headless Vulkan/Wayland acceptance")
34- }
35-}
36-
37-func binary(t *testing.T) string {
38- t.Helper()
39- path := filepath.Join(t.TempDir(), "rect")
40- cmd := exec.Command("go", "build", "-o", path, "./examples/rect")
41- cmd.Dir = filepath.Join("..", "..", "..")
42- cmd.Env = append(os.Environ(), "GOWORK=off")
43- if data, err := cmd.CombinedOutput(); err != nil {
44- t.Fatalf("build rect: %v\n%s", err, data)
45- }
46- return path
47-}
48-
49-type run struct {
50- root string
51- result harness.Result
52- capture, readback image.Image
53- timing []timing
54- lifecycle []lifecycle
55- summary summary
56-}
57-type timing struct {
58- Frame, Images, FDCount int
59- Buffer, Generation uint64
60-}
61-type lifecycle struct {
62- Frame int
63- Buffer, Generation, ReleasePoint uint64
64- Event string
65-}
66-type summary struct {
67- Frames int `json:"frames"`
68- FDStart int `json:"fd_start"`
69- FDPeak int `json:"fd_peak"`
70- FDAfter int `json:"fd_after"`
71- ImagesAfter int `json:"images_after"`
72-}
73-
74-func jsonLines[T any](t *testing.T, path string) []T {
75- t.Helper()
76- f, err := os.Open(path)
77- if err != nil {
78- t.Fatal(err)
79- }
80- defer f.Close()
81- var rows []T
82- scanner := bufio.NewScanner(f)
83- for scanner.Scan() {
84- var row T
85- if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
86- t.Fatalf("%s: %v", path, err)
87- }
88- rows = append(rows, row)
89- }
90- if err := scanner.Err(); err != nil {
91- t.Fatal(err)
92- }
93- if len(rows) == 0 {
94- t.Fatalf("empty trace: %s", path)
95- }
96- return rows
97-}
98-
99-func start(t *testing.T, bin, scale string, frames int, alpha bool) run {
100- return startSized(t, bin, scale, frames, alpha, "800x600")
101-}
102-func startSized(t *testing.T, bin, scale string, frames int, alpha bool, output string) run {
103- return startSizedMode(t, bin, scale, frames, alpha, output, false)
104-}
105-func startSizedMode(t *testing.T, bin, scale string, frames int, alpha bool, output string, list bool) run {
106- t.Helper()
107- root := filepath.Join(t.TempDir(), "artifacts")
108- args := []string{bin, "--static", "--frames", fmt.Sprint(frames)}
109- if list {
110- args = append(args, "--list")
111- }
112- if alpha {
113- args = append(args, "--transparent")
114- }
115- res, code := harness.Run(context.Background(), harness.Options{Size: output, Scale: scale, Layout: "us", Background: background, Out: root, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: args})
116- t.Logf("artifacts=%s code=%d status=%s", root, code, res.Status)
117- if code != harness.Pass {
118- data, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
119- t.Fatalf("harness: code=%d error=%s client stderr=%s", code, res.Error, data)
120- }
121- if res.Artifacts["capture"] == "" {
122- t.Fatal("static client did not request a mapped capture")
123- }
124- capture, err := harness.LoadPNG(res.Artifacts["capture"])
125- if err != nil {
126- t.Fatal(err)
127- }
128- files, err := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
129- if err != nil || len(files) == 0 {
130- t.Fatalf("readback: %v %v", files, err)
131- }
132- sort.Strings(files)
133- readback, err := harness.LoadPNG(files[len(files)-1])
134- if err != nil {
135- t.Fatal(err)
136- }
137- var s summary
138- data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
139- if err != nil {
140- t.Fatal(err)
141- }
142- if err := json.Unmarshal(data, &s); err != nil {
143- t.Fatal(err)
144- }
145- return run{root, res, capture, readback, jsonLines[timing](t, filepath.Join(res.Artifacts["debug"], "frame-timings.jsonl")), jsonLines[lifecycle](t, filepath.Join(res.Artifacts["debug"], "buffer-lifecycle.jsonl")), s}
146-}
147-
148-func size(img image.Image, w, h int) error {
149- if img.Bounds().Dx() != w || img.Bounds().Dy() != h {
150- return fmt.Errorf("size %v, want %dx%d", img.Bounds(), w, h)
151- }
152- return nil
153-}
154-func pixel(img image.Image, x, y int, want color.NRGBA) error {
155- return harness.PixelProbe(img, x, y, want, 2)
156-}
157-func check(t *testing.T, img image.Image, x, y int, want color.NRGBA) {
158- t.Helper()
159- if err := pixel(img, x, y, want); err != nil {
160- t.Fatal(err)
161- }
162-}
163-
164-// NeferWL configures the example's 400x300 toplevel to 800x600 logical.
165-func finalSize() (int, int) { return 800, 600 }
166-
167-func TestOpaque(t *testing.T) {
168- requireHarness(t)
169- r := start(t, binary(t), "1", 24, false)
170- w, h := finalSize()
171- if err := size(r.readback, w, h); err != nil {
172- t.Fatal(err)
173- }
174- check(t, r.readback, 0, 0, color.NRGBA{})
175- check(t, r.readback, w/2, h/2, opaque)
176- if err := size(r.capture, w, h); err != nil {
177- t.Fatal(err)
178- }
179- // The opaque window covers the output: its clear is black. NeferWL's
180- // presentation path applies a color transform to the orange rect.
181- check(t, r.capture, 0, 0, color.NRGBA{0, 0, 0, 255})
182- check(t, r.capture, w/2, h/2, color.NRGBA{251, 158, 65, 255})
183-}
184-func TestTransparent(t *testing.T) {
185- requireHarness(t)
186- r := start(t, binary(t), "1", 24, true)
187- w, h := finalSize()
188- if err := size(r.readback, w, h); err != nil {
189- t.Fatal(err)
190- }
191- check(t, r.readback, 0, 0, color.NRGBA{})
192- // NRGBA conversion unpremultiplies; inspect the encoded bytes directly.
193- rgba, ok := r.readback.(*image.NRGBA)
194- if !ok {
195- t.Fatalf("readback format %T", r.readback)
196- }
197- got := rgba.NRGBAAt(w/2, h/2)
198- for i, pair := range [][2]uint8{{got.R, transparent.R}, {got.G, transparent.G}, {got.B, transparent.B}, {got.A, transparent.A}} {
199- if math.Abs(float64(pair[0])-float64(pair[1])) > 2 {
200- t.Fatalf("premultiplied channel %d: got %v want %v", i, got, transparent)
201- }
202- }
203-}
204-func TestResize(t *testing.T) {
205- requireHarness(t)
206- r := start(t, binary(t), "1", 24, false)
207- max := uint64(0)
208- old := map[uint64]bool{}
209- retired := map[uint64]bool{}
210- for _, v := range r.timing {
211- if v.Generation > max {
212- max = v.Generation
213- }
214- if v.Generation == 1 {
215- old[v.Buffer] = true
216- }
217- }
218- if max <= 1 || len(old) == 0 {
219- t.Fatalf("missing configure: generation=%d old=%v", max, old)
220- }
221- for _, v := range r.lifecycle {
222- if old[v.Buffer] && (v.Event == "retired" || v.Event == "destroyed") {
223- retired[v.Buffer] = true
224- }
225- }
226- if len(retired) != len(old) {
227- t.Fatalf("old images not retired: old=%v retired=%v", old, retired)
228- }
229- w, h := finalSize()
230- if err := size(r.readback, w, h); err != nil {
231- t.Fatal(err)
232- }
233-}
234-func TestScale(t *testing.T) {
235- requireHarness(t)
236- bin := binary(t)
237- for _, tc := range []struct {
238- name string
239- factor float64
240- }{{"1.5", 1.5}, {"2", 2}} {
241- t.Run(tc.name, func(t *testing.T) {
242- // Use a physical output of 800x600 logical pixels at this scale.
243- // The compositor's --size is in physical output pixels.
244- r := startSized(t, bin, tc.name, 24, false, fmt.Sprintf("%dx%d", int(math.Ceil(800*tc.factor)), int(math.Ceil(600*tc.factor))))
245- w, h := int(math.Ceil(800*tc.factor)), int(math.Ceil(600*tc.factor))
246- if err := size(r.readback, w, h); err != nil {
247- t.Fatal(err)
248- }
249- check(t, r.readback, 0, 0, color.NRGBA{})
250- check(t, r.readback, w/2, h/2, opaque)
251- // Two pixels inside/outside the scaled 0.25 x boundary.
252- check(t, r.readback, w/4-3, h/2, color.NRGBA{})
253- check(t, r.readback, w/4+3, h/2, opaque)
254- })
255- }
256-}
257-func TestResources(t *testing.T) {
258- requireHarness(t)
259- r := start(t, binary(t), "1", 120, false)
260- s := r.summary
261- t.Logf("frames=%d fd_start=%d fd_peak=%d fd_after=%d images_after=%d", s.Frames, s.FDStart, s.FDPeak, s.FDAfter, s.ImagesAfter)
262- if s.Frames != 120 || len(r.timing) != 120 || s.ImagesAfter != 0 || s.FDPeak-s.FDStart > 2 || s.FDAfter > s.FDStart {
263- t.Fatalf("unbounded or leaked: %+v trace=%d", s, len(r.timing))
264- }
265- for _, v := range r.timing {
266- if v.Images > 6 || v.Images < 1 || v.FDCount > s.FDStart+2 {
267- t.Fatalf("frame %+v", v)
268- }
269- }
270-}
271-func TestValidation(t *testing.T) {
272- requireHarness(t)
273- if os.Getenv("NEFERGUI_VULKAN_VALIDATION") != "1" {
274- t.Skip("set NEFERGUI_VULKAN_VALIDATION=1 for Vulkan layer acceptance")
275- }
276- // vkconfig/vulkaninfo are not required: the loader resolves installed layers.
277- // A missing layer is a skip, not an untested successful run.
278- if !validationLayerAvailable() {
279- t.Skip("VK_LAYER_KHRONOS_validation not installed")
280- }
281- r := start(t, binary(t), "1", 30, false)
282- if r.summary.ImagesAfter != 0 || r.summary.FDAfter > r.summary.FDStart {
283- t.Fatalf("leaked objects: %+v", r.summary)
284- }
285- data, err := os.ReadFile(r.result.Artifacts["client_stderr"])
286- if err != nil {
287- t.Fatal(err)
288- }
289- if regexp.MustCompile(`(?i)(NEFERGUI_VULKAN_VALIDATION_(ERROR|WARNING)|validation (error|warning))`).Match(data) {
290- t.Fatalf("validation messages: %s", data)
291- }
292-}
293-func validationLayerAvailable() bool {
294- paths := []string{"/usr/share/vulkan/explicit_layer.d", "/etc/vulkan/explicit_layer.d"}
295- if v := os.Getenv("VK_LAYER_PATH"); v != "" {
296- paths = append(strings.Split(v, string(os.PathListSeparator)), paths...)
297- }
298- for _, dir := range paths {
299- files, _ := filepath.Glob(filepath.Join(dir, "*validation*.json"))
300- if len(files) > 0 {
301- return true
302- }
303- }
304- return false
305-}
306-
307-func TestCheckersNonVacuous(t *testing.T) {
308- img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
309- img.SetNRGBA(2, 2, opaque)
310- path := filepath.Join(t.TempDir(), "synthetic.png")
311- if err := harness.SavePNG(path, img); err != nil {
312- t.Fatal(err)
313- }
314- decoded, err := harness.LoadPNG(path)
315- if err != nil {
316- t.Fatal(err)
317- }
318- if err := pixel(decoded, 2, 2, opaque); err != nil {
319- t.Fatal(err)
320- }
321- if err := pixel(decoded, 2, 2, color.NRGBA{0, 0, 255, 255}); err == nil {
322- t.Fatal("wrong color accepted")
323- }
324- if err := size(decoded, 4, 4); err != nil {
325- t.Fatal(err)
326- }
327- if err := size(decoded, 5, 4); err == nil {
328- t.Fatal("wrong size accepted")
329- }
330-}
331-
332-func TestListQuads(t *testing.T) {
333- requireHarness(t)
334- r := startSizedMode(t, binary(t), "1", 24, false, "800x600", true)
335- img := r.readback
336- probes := []struct {
337- x, y int
338- want color.NRGBA
339- }{
340- {100, 100, color.NRGBA{}}, // rounded corner
341- {120, 120, color.NRGBA{255, 0, 0, 255}}, // inside round
342- {340, 160, color.NRGBA{}}, // outside green clip
343- {250, 160, color.NRGBA{0, 255, 0, 255}}, // clipped green fill
344- {160, 200, color.NRGBA{128, 0, 128, 255}}, // half-blue over red
345- }
346- for _, p := range probes {
347- got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA)
348- t.Logf("pixel (%d,%d)=%v want=%v", p.x, p.y, got, p.want)
349- for _, pair := range [][2]uint8{{got.R, p.want.R}, {got.G, p.want.G}, {got.B, p.want.B}, {got.A, p.want.A}} {
350- if math.Abs(float64(pair[0])-float64(pair[1])) > 3 {
351- t.Fatalf("pixel (%d,%d)=%v want=%v", p.x, p.y, got, p.want)
352- }
353- }
354- }
355-}
356-
357-func TestListStyles(t *testing.T) {
358- requireHarness(t)
359- bin := binary(t)
360- for _, scale := range []int{1, 2} {
361- t.Run(fmt.Sprint(scale), func(t *testing.T) {
362- root := filepath.Join(t.TempDir(), "artifacts")
363- res, code := harness.Run(context.Background(), harness.Options{Size: "800x600", Scale: "1", Layout: "us", Background: background, Out: root, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{bin, "--static", "--styles", "--scale", fmt.Sprint(scale), "--frames", "24"}})
364- t.Logf("artifacts=%s code=%d status=%s", root, code, res.Status)
365- if code != harness.Pass {
366- data, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
367- t.Fatalf("harness: %s %s", res.Error, data)
368- }
369- files, _ := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
370- sort.Strings(files)
371- if len(files) == 0 {
372- t.Fatal("missing readback")
373- }
374- img, err := harness.LoadPNG(files[len(files)-1])
375- if err != nil {
376- t.Fatal(err)
377- }
378- bg := color.NRGBA{17, 17, 17, 255}
379- probes := []struct {
380- name string
381- x, y int
382- want color.NRGBA
383- }{
384- {"top border", 200 * scale, 123 * scale, color.NRGBA{255, 0, 0, 255}},
385- {"right border", 276 * scale, 180 * scale, color.NRGBA{0, 255, 0, 255}},
386- {"bottom border", 200 * scale, 236 * scale, color.NRGBA{0, 0, 255, 255}},
387- {"left border", 123 * scale, 180 * scale, color.NRGBA{255, 255, 0, 255}},
388- {"interior", 200 * scale, 180 * scale, color.NRGBA{255, 255, 255, 255}},
389- {"rounded corner", 113 * scale, 113 * scale, bg},
390- {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}},
391- {"far", 200 * scale, 285 * scale, bg},
392- {"single-side left corner", 300 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
393- {"single-side right corner", 379 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
394- {"unequal corner top", 301 * scale, 242 * scale, color.NRGBA{255, 0, 0, 255}},
395- {"unequal corner left", 300 * scale, 246 * scale, color.NRGBA{0, 0, 255, 255}},
396- }
397- for _, p := range probes {
398- got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA)
399- t.Logf("scale=%d %s (%d,%d)=%v want=%v", scale, p.name, p.x, p.y, got, p.want)
400- if err := harness.PixelProbe(img, p.x, p.y, p.want, 3); err != nil {
401- t.Error(err)
402- }
403- }
404- x, y := 200*scale, 252*scale
405- shadow := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
406- t.Logf("scale=%d shadow (%d,%d)=%v background=%v", scale, x, y, shadow, bg)
407- if shadow.R > bg.R-3 || shadow.G > bg.G-3 || shadow.B > bg.B-3 {
408- t.Errorf("shadow not darker: %v", shadow)
409- }
410- if os.Getenv("NEFERGUI_VULKAN_VALIDATION") == "1" {
411- data, err := os.ReadFile(res.Artifacts["client_stderr"])
412- if err != nil {
413- t.Fatal(err)
414- }
415- if regexp.MustCompile(`(?i)(NEFERGUI_VULKAN_VALIDATION_(ERROR|WARNING)|validation (error|warning))`).Match(data) {
416- t.Errorf("validation: %s", data)
417- }
418- }
419- })
420- }
421-}
D internal/presentation/systemtest/text_test.go +0 -157
@@ -1,157 +0,0 @@
1-//go:build linux
2-
3-package systemtest
4-
5-import (
6- "context"
7- "encoding/json"
8- "fmt"
9- "image"
10- "math"
11- "os"
12- "path/filepath"
13- "regexp"
14- "sort"
15- "testing"
16- "time"
17-
18- "github.com/bnema/nefergui/internal/css"
19- "github.com/bnema/nefergui/internal/harness"
20- "github.com/bnema/nefergui/internal/layout"
21- "github.com/bnema/nefergui/internal/presentation/vkdevice"
22- "github.com/bnema/nefergui/internal/render"
23- "github.com/bnema/nefergui/internal/text"
24-)
25-
26-const sampleText = "office سلام 日本語"
27-
28-func TestListText(t *testing.T) {
29- requireHarness(t)
30- bin := binary(t)
31- catalog, err := text.Load(text.DirectorySource("../../../testdata/fonts"))
32- if err != nil {
33- t.Fatal(err)
34- }
35- measured, err := text.NewEngine(catalog).Measure(sampleText, text.Request{Families: []string{"Noto Sans"}, Size: 32}, 0)
36- if err != nil {
37- t.Fatal(err)
38- }
39- cmd := layout.Command{Op: "text", Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}
40- for _, line := range measured.Lines {
41- for _, shaped := range line.Runs {
42- r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 32}
43- for _, g := range shaped.Glyphs {
44- r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 20 + g.X, Y: 40 + g.Y})
45- }
46- cmd.Runs = append(cmd.Runs, r)
47- }
48- }
49- for _, scale := range []int{1, 2} {
50- t.Run(fmt.Sprint(scale), func(t *testing.T) {
51- prep, err := render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages)
52- if err != nil {
53- t.Fatal(err)
54- }
55- frame, err := prep.Prepare([]layout.Command{cmd}, float64(scale), 800, 600)
56- if err != nil {
57- t.Fatal(err)
58- }
59- if len(frame.Quads) == 0 || len(frame.Uploads) == 0 {
60- t.Fatal("no glyphs or uploads")
61- }
62- root := filepath.Join(t.TempDir(), "artifacts")
63- res, code := harness.Run(context.Background(), harness.Options{Size: "800x600", Scale: "1", Layout: "us", Background: background, Out: root, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{bin, "--static", "--text", sampleText, "--scale", fmt.Sprint(scale), "--frames", "120"}})
64- t.Logf("artifacts=%s code=%d status=%s", root, code, res.Status)
65- if code != harness.Pass {
66- data, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
67- t.Fatalf("harness: %s: %s", res.Error, data)
68- }
69- files, _ := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
70- sort.Strings(files)
71- if len(files) == 0 {
72- t.Fatal("no readback")
73- }
74- img, err := harness.LoadPNG(files[len(files)-1])
75- if err != nil {
76- t.Fatal(err)
77- }
78- var sum summary
79- data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
80- if err != nil {
81- t.Fatal(err)
82- }
83- if err = json.Unmarshal(data, &sum); err != nil {
84- t.Fatal(err)
85- }
86- if sum.Frames != 120 || sum.ImagesAfter != 0 || sum.FDPeak-sum.FDStart > 2 || sum.FDAfter > sum.FDStart {
87- t.Fatalf("resource counts: %+v", sum)
88- }
89- timing := jsonLines[timing](t, filepath.Join(res.Artifacts["debug"], "frame-timings.jsonl"))
90- if len(timing) != 120 {
91- t.Fatalf("timings=%d", len(timing))
92- }
93- for _, row := range timing {
94- if row.Images < 1 || row.Images > 6 || row.FDCount > sum.FDStart+2 {
95- t.Fatalf("unstable frame: %+v", row)
96- }
97- }
98- if os.Getenv("NEFERGUI_VULKAN_VALIDATION") == "1" {
99- data, err := os.ReadFile(res.Artifacts["client_stderr"])
100- if err != nil {
101- t.Fatal(err)
102- }
103- if regexp.MustCompile(`(?i)(NEFERGUI_VULKAN_VALIDATION_(ERROR|WARNING)|validation (error|warning))`).Match(data) {
104- t.Fatalf("validation: %s", data)
105- }
106- }
107- // Reconstruct the CPU coverage directly from the same rasterized masks
108- // that Prepare packed into atlas upload rectangles.
109- pages := make([][]byte, vkdevice.AtlasPages)
110- for i := range pages {
111- pages[i] = make([]byte, vkdevice.AtlasSize*vkdevice.AtlasSize)
112- }
113- for _, u := range frame.Uploads {
114- for y := 0; y < u.Rect.Dy(); y++ {
115- copy(pages[u.Page][(u.Rect.Min.Y+y)*vkdevice.AtlasSize+u.Rect.Min.X:], u.Bytes[y*u.Rect.Dx():(y+1)*u.Rect.Dx()])
116- }
117- }
118- var box image.Rectangle
119- expected := make([]byte, 800*600)
120- for _, q := range frame.Quads {
121- if q.Op != "glyph" {
122- continue
123- }
124- r := image.Rect(int(q.Rect.X), int(q.Rect.Y), int(q.Rect.X+q.Rect.W), int(q.Rect.Y+q.Rect.H))
125- box = box.Union(r)
126- for y := 0; y < r.Dy(); y++ {
127- for x := 0; x < r.Dx(); x++ {
128- expected[(r.Min.Y+y)*800+r.Min.X+x] = pages[q.Glyph.Page][(q.Glyph.Rect.Min.Y+y)*vkdevice.AtlasSize+q.Glyph.Rect.Min.X+x]
129- }
130- }
131- }
132- var ink image.Rectangle
133- cpu, gpu := 0.0, 0.0
134- for y := 0; y < 600; y++ {
135- for x := 0; x < 800; x++ {
136- p := img.At(x, y)
137- r16, g16, b16, a16 := p.RGBA()
138- r, g, b, a := r16>>8, g16>>8, b16>>8, a16>>8
139- if expected[y*800+x] > 0 {
140- cpu += float64(expected[y*800+x]) / 255
141- }
142- if !image.Pt(x, y).In(box) && (r != 17 || g != 17 || b != 17 || a != 255) {
143- t.Fatalf("outside ink box at %d,%d: %v", x, y, p)
144- }
145- if r > 17 {
146- gpu += float64(r-17) / 238
147- ink = ink.Union(image.Rect(x, y, x+1, y+1))
148- }
149- }
150- }
151- if ink.Empty() || math.Abs(float64(ink.Min.X-box.Min.X)) > 2 || math.Abs(float64(ink.Min.Y-box.Min.Y)) > 2 || math.Abs(float64(ink.Max.X-box.Max.X)) > 2 || math.Abs(float64(ink.Max.Y-box.Max.Y)) > 2 || math.Abs(gpu-cpu) > cpu*.1 {
152- t.Fatalf("ink=%v measure/raster box=%v coverage gpu=%.2f cpu=%.2f", ink, box, gpu, cpu)
153- }
154- t.Logf("scale=%d ink=%v measure/raster box=%v gpu coverage=%.2f cpu coverage=%.2f resources=%+v", scale, ink, box, gpu, cpu, sum)
155- })
156- }
157-}
D internal/ui/headless_test.go +0 -209
@@ -1,209 +0,0 @@
1-package ui
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "strings"
10- "sync"
11- "testing"
12- "time"
13-
14- "github.com/bnema/nefergui/internal/harness"
15-)
16-
17-// These tests drive the real Run loop against a headless NeferWL. They run only
18-// when NEFERGUI_NEFERWL points at a compositor build; the client is this test
19-// binary re-executed, since Run needs a compositor socket in its environment.
20-
21-const headlessClientEnv = "NEFERGUI_UI_HEADLESS_SCENARIO"
22-
23-func requireCompositor(t *testing.T) {
24- t.Helper()
25- if os.Getenv("NEFERGUI_NEFERWL") == "" {
26- t.Skip("NEFERGUI_NEFERWL not set")
27- }
28-}
29-
30-func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) []string {
31- t.Helper()
32- return runHeadlessSize(t, "640x480", scenario, script, timeout)
33-}
34-
35-func runHeadlessSize(t *testing.T, size, scenario, script string, timeout time.Duration) []string {
36- t.Helper()
37- requireCompositor(t)
38- dir := t.TempDir()
39- events := filepath.Join(dir, "events.txt")
40- input := ""
41- if script != "" {
42- input = filepath.Join(dir, "input")
43- if err := os.WriteFile(input, []byte(script), 0600); err != nil {
44- t.Fatal(err)
45- }
46- }
47- t.Setenv(headlessClientEnv, scenario)
48- t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events)
49- res, code := harness.Run(context.Background(), harness.Options{
50- Size: size, Scale: "1", Layout: "us", Background: "#000000",
51- Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true,
52- Timeout: timeout, ReadyTimeout: 5 * time.Second,
53- Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"},
54- })
55- data, _ := os.ReadFile(events)
56- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
57- if code != harness.Pass {
58- stderr, _ := os.ReadFile(res.Artifacts["client_stderr"])
59- t.Fatalf("harness code %d: %s\nevents:\n%s\nstderr:\n%s", code, res.Error, data, stderr)
60- }
61- return lines
62-}
63-
64-func indexOf(lines []string, prefix string) int {
65- for i, l := range lines {
66- if strings.HasPrefix(l, prefix) {
67- return i
68- }
69- }
70- return -1
71-}
72-
73-func TestHeadlessLayerHooks(t *testing.T) {
74- lines := runHeadless(t, "layer", "sleep 3s\nmove 5 5\nsleep 200ms\nclick left\nkey a\nkey Escape\nsleep 500ms\n", 30*time.Second)
75- surface, view := indexOf(lines, "surface"), indexOf(lines, "view ")
76- if surface != 0 || view < 0 || surface > view {
77- t.Fatalf("OnSurface must run once, before the first view:\n%s", strings.Join(lines, "\n"))
78- }
79- count := 0
80- for _, l := range lines {
81- if l == "surface" {
82- count++
83- }
84- }
85- joined := "\n" + strings.Join(lines, "\n") + "\n"
86- if count != 1 || strings.Count(joined, "\nresize ") != 1 || !strings.Contains(joined, "\nresize 640 480 1\n") {
87- t.Fatalf("surface %d, resize must fire once with 640x480:\n%s", count, joined)
88- }
89- if w := indexOf(lines, "wake-sent"); w < 0 || indexOf(lines[w:], "view ") < 0 || indexOf(lines[w:], "view ") > indexOf(lines[w:], "input press") {
90- t.Errorf("wake must produce a view before input starts:\n%s", joined)
91- }
92- for _, want := range []string{"\nview size 640x480", "\ninput press", "\ninput key Escape", "\nclicks 1\n", "\nexit context canceled\n"} {
93- if !strings.Contains(joined, want) {
94- t.Errorf("missing %q in:\n%s", want, joined)
95- }
96- }
97-}
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-
118-func TestHeadlessSurfaceHookErrorAborts(t *testing.T) {
119- lines := runHeadless(t, "hook-error", "", 20*time.Second)
120- joined := strings.Join(lines, "\n")
121- if !strings.Contains(joined, "exit nefergui: surface hook: hook failed") || strings.Contains(joined, "view") {
122- t.Fatalf("hook error must abort before any view:\n%s", joined)
123- }
124-}
125-
126-func TestHeadlessSurfaceHookCancellation(t *testing.T) {
127- lines := runHeadless(t, "hook-cancel", "", 20*time.Second)
128- joined := strings.Join(lines, "\n")
129- if !strings.Contains(joined, "exit context canceled") || strings.Contains(joined, "view") {
130- t.Fatalf("cancellation during the hook must return ctx.Err before any view:\n%s", joined)
131- }
132-}
133-
134-// TestHeadlessClientProcess is the client half; it does nothing under go test.
135-func TestHeadlessClientProcess(t *testing.T) {
136- scenario := os.Getenv(headlessClientEnv)
137- if scenario == "" {
138- t.Skip("client process only")
139- }
140- out, err := os.Create(os.Getenv("NEFERGUI_UI_HEADLESS_OUT"))
141- if err != nil {
142- t.Fatal(err)
143- }
144- var mu sync.Mutex
145- logf := func(format string, args ...any) {
146- mu.Lock()
147- defer mu.Unlock()
148- fmt.Fprintf(out, format+"\n", args...)
149- }
150- type model struct{ clicks int }
151- m := &model{}
152- ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
153- defer cancel()
154- view := func(f *Frame, m *model) {
155- w, h := f.Size()
156- logf("view size %vx%v", w, h)
157- st := f.Root().Stack()
158- st.Box(Key("box")).Rect(10, 20, 50, 40)
159- if st.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
160- m.clicks++
161- logf("clicks %d", m.clicks)
162- }
163- }
164- options := []WindowOption{Size(300, 200), Transparent()}
165- switch scenario {
166- case "layer":
167- wake := make(chan struct{}, 1)
168- // Nothing else redraws an idle window before input starts at 3s.
169- go func() { time.Sleep(1500 * time.Millisecond); logf("wake-sent"); wake <- struct{}{} }()
170- options = append(options,
171- Layer(LayerConfig{Level: LayerOverlay, Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, Keyboard: KeyboardExclusive}),
172- Wake(wake),
173- OnSurface(func(context.Context, WaylandSurface) error { logf("surface"); return nil }),
174- OnResize(func(w, h int, s float64) { logf("resize %d %d %v", w, h, s) }),
175- OnInput(func(ev InputEvent) bool {
176- switch {
177- case ev.Kind == InputPointerPress:
178- logf("input press")
179- return true
180- case ev.Kind == InputKey && ev.Pressed && ev.KeyName == "Escape":
181- logf("input key Escape")
182- cancel()
183- }
184- return false
185- }),
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{}}))
194- case "hook-error":
195- options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
196- OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") }))
197- case "hook-cancel":
198- options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
199- OnSurface(func(ctx context.Context, _ WaylandSurface) error {
200- go func() { time.Sleep(200 * time.Millisecond); cancel() }()
201- <-ctx.Done()
202- return nil
203- }))
204- }
205- err = Run(ctx, m, view, options...)
206- logf("exit %v", err)
207- out.Close()
208- os.Exit(0)
209-}
D internal/ui/hooks.go +0 -254
@@ -1,254 +0,0 @@
1-package ui
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "math"
8-
9- "github.com/bnema/wlturbo/protocol/core"
10- "github.com/bnema/wlturbo/wl"
11-
12- "github.com/bnema/nefergui/internal/platform/wayland"
13-)
14-
15-// InputEvent is a raw platform event, delivered on the owner loop before the
16-// normal control routing. Coordinates are logical, surface-local pixels.
17-// Size changes are not input; use OnResize and Frame.Size. Which fields are set
18-// depends on Kind:
19-//
20-// pointer motion/press/release/axis: X, Y; press/release: Button (evdev code)
21-// and Pressed; axis: DX, DY
22-// key: KeyName, Text, Modifiers, Pressed, Repeat
23-type InputEvent struct {
24- Kind InputKind
25- X, Y float64
26- DX, DY float64
27- Button uint32
28- Pressed bool
29- Repeat bool
30- KeyName string
31- Text string
32- Modifiers Modifiers
33-}
34-
35-// OnInput delivers every raw input event to fn on the session owner loop,
36-// before the event reaches NeferGUI controls; controls still receive it. fn may
37-// mutate the model the view reads. It returns true when it changed state the
38-// view shows, which schedules a redraw; a false return costs no frame, so
39-// pointer motion does not force a build. A nil fn is ignored. fn must not block.
40-func OnInput(fn func(InputEvent) bool) WindowOption {
41- return windowOption(func(w *windowConfig) {
42- if fn != nil {
43- w.onInput = fn
44- }
45- })
46-}
47-
48-// OnResize reports the logical size and scale once at start and again only
49-// when one of them changes; a change always schedules a redraw. It runs on the
50-// owner loop before the frame is built. A nil fn is ignored.
51-func OnResize(fn func(width, height int, scale float64)) WindowOption {
52- return windowOption(func(w *windowConfig) {
53- if fn != nil {
54- w.onResize = fn
55- }
56- })
57-}
58-
59-// Wake requests a redraw whenever a value arrives on ch, so a view can react
60-// to state changed by other goroutines. The caller synchronizes such state;
61-// the view still runs only on the owner loop. Closing ch stops forwarding. A nil ch is ignored.
62-func Wake(ch <-chan struct{}) WindowOption {
63- return windowOption(func(w *windowConfig) {
64- if ch != nil {
65- w.wake = ch
66- }
67- })
68-}
69-
70-// inputHooks holds the optional callbacks; all methods run on the owner loop.
71-type inputHooks struct {
72- onInput func(InputEvent) bool
73- onResize func(int, int, float64)
74- haveSize bool
75- width, height int
76- scale float64
77-}
78-
79-func toInputEvent(in wayland.Input) (InputEvent, bool) {
80- ev := InputEvent{X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button}
81- switch in.Kind {
82- case "motion":
83- ev.Kind = InputPointerMotion
84- case "press":
85- ev.Kind, ev.Pressed = InputPointerPress, true
86- case "release":
87- ev.Kind = InputPointerRelease
88- case "axis":
89- ev.Kind = InputPointerAxis
90- case "leave":
91- ev.Kind = InputPointerLeave
92- case "key":
93- ev.Kind = InputKey
94- ev.KeyName, ev.Text = in.Key.Name, in.Key.Text
95- ev.Pressed, ev.Repeat = in.Key.Pressed, in.Key.Repeat
96- if in.Key.Shift {
97- ev.Modifiers |= ModShift
98- }
99- if in.Key.Ctrl {
100- ev.Modifiers |= ModCtrl
101- }
102- case "focus-in":
103- ev.Kind = InputFocusIn
104- case "focus-out":
105- ev.Kind = InputFocusOut
106- case wayland.InputReset:
107- ev.Kind = InputReset
108- default:
109- return InputEvent{}, false
110- }
111- return ev, true
112-}
113-
114-// input reports one platform input and whether the hook changed view state;
115-// it never allocates.
116-func (h *inputHooks) input(in wayland.Input) bool {
117- if h.onInput == nil {
118- return false
119- }
120- if ev, ok := toInputEvent(in); ok {
121- return h.onInput(ev)
122- }
123- return false
124-}
125-
126-// resize reports a size or scale only when it differs from the last report and
127-// reports whether it did.
128-func (h *inputHooks) resize(width, height int, scale float64) bool {
129- if h.haveSize && h.width == width && h.height == height && h.scale == scale {
130- return false
131- }
132- h.haveSize, h.width, h.height, h.scale = true, width, height, scale
133- if h.onResize != nil {
134- h.onResize(width, height, scale)
135- }
136- return true
137-}
138-
139-// forwardWake turns external wake tokens into redraw requests. Redraw records
140-// the request before signalling the owner loop, so the next Build is not idle.
141-func (r *runtime) forwardWake(ctx context.Context, ch <-chan struct{}) {
142- for {
143- select {
144- case <-ctx.Done():
145- return
146- case _, ok := <-ch:
147- if !ok {
148- return
149- }
150- r.Redraw()
151- }
152- }
153-}
154-
155-// WaylandSurface exposes the window's Wayland connection and wl_surface so an
156-// integrator can register extra protocol objects on the same client, such as
157-// clipboard or surface-marking extensions. It is the escape hatch to the
158-// underlying wlturbo objects; it does not add any protocol itself.
159-//
160-// The values are owned by NeferGUI and valid until Run returns. Do not close
161-// the Display, destroy the Surface or attach buffers. Events for objects the
162-// callback creates are dispatched on the owner goroutine while requests such as
163-// roundtrips run before the event reader starts, and on NeferGUI's reader
164-// goroutine afterwards, so handlers must synchronize any state they share.
165-type WaylandSurface struct {
166- Display *wl.Display
167- Surface *core.Surface
168-}
169-
170-// OnSurface calls fn once on the owner goroutine, after the surface has its
171-// role (xdg toplevel or layer surface) and its first configure, and before the
172-// first buffer is attached, so the window is not yet visible. ctx is the Run
173-// context: if it is cancelled while fn runs, NeferGUI closes the display so
174-// blocking protocol calls fail, and Run returns ctx.Err() (joined with any
175-// other error fn returned). A non-nil error aborts Run and closes the session.
176-// A nil fn is ignored.
177-func OnSurface(fn func(context.Context, WaylandSurface) error) WindowOption {
178- return windowOption(func(w *windowConfig) {
179- if fn != nil {
180- w.onSurface = fn
181- }
182- })
183-}
184-
185-// SetInputRects stages the input region for the surface: pointer input outside
186-// the rectangles passes through. It is applied after the view returns and
187-// and committed together with the frame's buffer, with no extra commit. nil restores the whole surface, and a non-nil
188-// empty slice makes the surface click-through. Rects need positive size. The
189-// slice is copied, so callers may reuse it; an unchanged region costs nothing.
190-// If the view never calls it, the Layer config's InputRects stay in effect.
191-func (f *Frame) SetInputRects(rects []Rect) error {
192- if !f.active {
193- panic("nefergui: SetInputRects outside frame")
194- }
195- for i, r := range rects {
196- if r.Width <= 0 || r.Height <= 0 || int64(r.X)+int64(r.Width) > math.MaxInt32 || int64(r.Y)+int64(r.Height) > math.MaxInt32 {
197- return fmt.Errorf("nefergui: input rect %d %+v is empty or overflows", i, r)
198- }
199- }
200- o := f.owner
201- o.inputNil = rects == nil
202- o.inputRects = append(o.inputRects[:0], rects...)
203- o.inputStaged = true
204- return nil
205-}
206-
207-// applyInputRects hands a staged region to the platform's stage function, which
208-// must not commit: the region rides the frame's own buffer commit. The region
209-// stays staged until stage succeeds, so a failure is retried on the next frame.
210-func (r *runtime) applyInputRects(stage func([]wayland.Rect) error) error {
211- if !r.inputStaged {
212- return nil
213- }
214- if r.inputNil {
215- if err := stage(nil); err != nil {
216- return err
217- }
218- r.inputStaged = false
219- return nil
220- }
221- out := r.inputOut[:0]
222- if out == nil {
223- out = []wayland.Rect{}
224- }
225- for _, q := range r.inputRects {
226- out = append(out, wayland.Rect{X: q.X, Y: q.Y, W: q.Width, H: q.Height})
227- }
228- r.inputOut = out
229- if err := stage(out); err != nil {
230- return err
231- }
232- r.inputStaged = false
233- return nil
234-}
235-
236-// startupHook runs the OnSurface callback. When ctx can be cancelled, the
237-// display is closed on cancellation so blocking calls in fn return; a
238-// cancelled ctx is reported joined with fn's own error.
239-func startupHook(ctx context.Context, fn func(context.Context, WaylandSurface) error, s WaylandSurface) error {
240- if ctx.Done() != nil {
241- closed := make(chan struct{})
242- stop := context.AfterFunc(ctx, func() { _ = s.Display.Close(); close(closed) })
243- defer func() {
244- if !stop() {
245- <-closed // the close callback already started; let it finish
246- }
247- }()
248- }
249- err := fn(ctx, s)
250- if cerr := ctx.Err(); cerr != nil {
251- return errors.Join(cerr, err)
252- }
253- return err
254-}
D internal/ui/hooks_test.go +0 -451
@@ -1,451 +0,0 @@
1-package ui
2-
3-import (
4- "context"
5- "errors"
6- "math"
7- "net"
8- "path/filepath"
9- "strconv"
10- "testing"
11- "time"
12-
13- "github.com/bnema/wlturbo"
14- "github.com/bnema/wlturbo/wl"
15-
16- "github.com/bnema/nefergui/internal/css"
17- "github.com/bnema/nefergui/internal/keyboard"
18- "github.com/bnema/nefergui/internal/layout"
19- "github.com/bnema/nefergui/internal/platform/wayland"
20- "github.com/bnema/nefergui/internal/text"
21-)
22-
23-func TestLayerConfigSurfaceOptions(t *testing.T) {
24- c := LayerConfig{Output: "DP-1", Namespace: "ns", Level: LayerOverlay, Anchors: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom,
25- Keyboard: KeyboardExclusive, ExclusiveZone: -1, Margin: [4]int32{1, 2, 3, 4}, InputRects: []Rect{{1, 2, 3, 4}}}
26- got, err := c.surfaceOptions()
27- if err != nil {
28- t.Fatal(err)
29- }
30- l := got.Layer
31- if l == nil || l.Output != "DP-1" || l.Namespace != "ns" || l.Layer != wayland.LayerOverlay || l.Anchor != 15 ||
32- l.Keyboard != wayland.KeyboardExclusive || l.ExclusiveZone != -1 || l.Margin != [4]int32{1, 2, 3, 4} {
33- t.Fatalf("layer options: %+v", l)
34- }
35- if len(got.InputRects) != 1 || got.InputRects[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
36- t.Fatalf("input rects: %+v", got.InputRects)
37- }
38- for level, want := range map[LayerLevel]uint32{LayerBackground: 0, LayerBottom: 1, LayerTop: 2, LayerOverlay: 3} {
39- o, err := LayerConfig{Level: level}.surfaceOptions()
40- if err != nil || o.Layer.Layer != want {
41- t.Fatalf("level %d: %+v %v", level, o.Layer, err)
42- }
43- }
44- // nil InputRects keeps the full surface; empty non-nil means click-through.
45- if o, _ := (LayerConfig{Level: LayerTop}).surfaceOptions(); o.InputRects != nil {
46- t.Fatal("nil rects became non-nil")
47- }
48- if o, _ := (LayerConfig{Level: LayerTop, InputRects: []Rect{}}).surfaceOptions(); o.InputRects == nil {
49- t.Fatal("empty rects became nil")
50- }
51-}
52-
53-func TestLayerConfigRejectsInvalid(t *testing.T) {
54- for name, c := range map[string]LayerConfig{
55- "zero level": {},
56- "bad level": {Level: 9},
57- "bad anchor": {Level: LayerTop, Anchors: 1 << 5},
58- "bad keyboard": {Level: LayerTop, Keyboard: 7},
59- "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}},
60- "nul output": {Level: LayerTop, Output: "a\x00"},
61- "all+output": {Level: LayerTop, AllOutputs: true, Output: "DP-1"},
62- "all+keyboard": {Level: LayerTop, AllOutputs: true, Keyboard: KeyboardOnDemand},
63- } {
64- if _, err := c.surfaceOptions(); err == nil {
65- t.Errorf("%s: expected error", name)
66- }
67- }
68-}
69-
70-func TestLayerCopiesInputRects(t *testing.T) {
71- rects := []Rect{{0, 0, 1, 1}}
72- var cfg windowConfig
73- Layer(LayerConfig{Level: LayerTop, InputRects: rects}).window(&cfg)
74- rects[0].Width = 99
75- if cfg.layer.InputRects[0].Width != 1 {
76- t.Fatal("option aliases caller slice")
77- }
78-}
79-
80-func TestInputEventMapping(t *testing.T) {
81- ev, ok := toInputEvent(wayland.Input{Kind: "press", X: 1.5, Y: 2.5, Button: 0x110})
82- if !ok || ev.Kind != InputPointerPress || !ev.Pressed || ev.X != 1.5 || ev.Y != 2.5 || ev.Button != 0x110 {
83- t.Fatalf("press: %+v", ev)
84- }
85- ev, _ = toInputEvent(wayland.Input{Kind: "release", Button: 0x110})
86- if ev.Kind != InputPointerRelease || ev.Pressed {
87- t.Fatalf("release: %+v", ev)
88- }
89- ev, ok = toInputEvent(wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Text: "x", Pressed: true, Repeat: true, Shift: true, Ctrl: true}})
90- if !ok || ev.Kind != InputKey || ev.KeyName != "Escape" || ev.Text != "x" || !ev.Pressed || !ev.Repeat || ev.Modifiers != ModShift|ModCtrl {
91- t.Fatalf("key: %+v", ev)
92- }
93- ev, _ = toInputEvent(wayland.Input{Kind: "axis", DX: 1, DY: -2})
94- if ev.Kind != InputPointerAxis || ev.DX != 1 || ev.DY != -2 {
95- t.Fatalf("axis: %+v", ev)
96- }
97- for kind, want := range map[string]InputKind{"motion": InputPointerMotion, "leave": InputPointerLeave, "focus-in": InputFocusIn, "focus-out": InputFocusOut, wayland.InputReset: InputReset} {
98- if ev, ok = toInputEvent(wayland.Input{Kind: kind}); !ok || ev.Kind != want {
99- t.Fatalf("%s: %+v", kind, ev)
100- }
101- }
102- if _, ok = toInputEvent(wayland.Input{Kind: "unknown"}); ok {
103- t.Fatal("unknown kind delivered")
104- }
105-}
106-
107-func TestInputHookDelivery(t *testing.T) {
108- var got []InputEvent
109- h := &inputHooks{onInput: func(ev InputEvent) bool { got = append(got, ev); return ev.Kind == InputPointerPress }}
110- if h.input(wayland.Input{Kind: "motion", X: 3, Y: 4}) {
111- t.Fatal("motion reported a change")
112- }
113- if !h.input(wayland.Input{Kind: "press", Button: 1}) {
114- t.Fatal("press change not reported")
115- }
116- if len(got) != 2 || got[0].Kind != InputPointerMotion {
117- t.Fatalf("got %+v", got)
118- }
119- // Absent callbacks and steady-state delivery do not allocate.
120- idle := &inputHooks{}
121- in := wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Pressed: true}}
122- if n := testing.AllocsPerRun(100, func() { idle.input(in) }); n != 0 {
123- t.Fatalf("idle input allocs %v", n)
124- }
125- count := 0
126- live := &inputHooks{onInput: func(InputEvent) bool { count++; return false }}
127- if n := testing.AllocsPerRun(100, func() { live.input(in) }); n != 0 {
128- t.Fatalf("hooked input allocs %v", n)
129- }
130- if n := testing.AllocsPerRun(100, func() { idle.resize(10, 10, 1) }); n != 0 {
131- t.Fatalf("idle resize allocs %v", n)
132- }
133-}
134-
135-func TestResizeReportedOnlyOnChange(t *testing.T) {
136- type size struct {
137- w, h int
138- s float64
139- }
140- var got []size
141- h := &inputHooks{onResize: func(w, hh int, s float64) { got = append(got, size{w, hh, s}) }}
142- var changed []bool
143- for _, s := range []size{{100, 50, 1}, {100, 50, 1}, {100, 60, 1}, {100, 60, 1}, {100, 60, 2}, {100, 60, 2}} {
144- changed = append(changed, h.resize(s.w, s.h, s.s))
145- }
146- want := []size{{100, 50, 1}, {100, 60, 1}, {100, 60, 2}}
147- if len(got) != len(want) {
148- t.Fatalf("resize reports %v", got)
149- }
150- for i := range want {
151- if got[i] != want[i] {
152- t.Fatalf("report %d: %v", i, got[i])
153- }
154- }
155- if !changed[0] || changed[1] || !changed[2] || changed[3] || !changed[4] || changed[5] {
156- t.Fatalf("changed flags %v", changed)
157- }
158-}
159-
160-func TestExternalWakeForcesBuild(t *testing.T) {
161- r := newRuntime()
162- builds := 0
163- view := func(f *Frame) { builds++; f.Root() }
164- r.Build(view)
165- if r.Build(view) {
166- t.Fatal("idle runtime rebuilt")
167- }
168- ch := make(chan struct{})
169- ctx, cancel := context.WithCancel(context.Background())
170- defer cancel()
171- done := make(chan struct{})
172- go func() { r.forwardWake(ctx, ch); close(done) }()
173- ch <- struct{}{}
174- waitCtx, waitCancel := context.WithTimeout(ctx, time.Second)
175- defer waitCancel()
176- if err := r.Wait(waitCtx); err != nil {
177- t.Fatal("wake not forwarded:", err)
178- }
179- if !r.Build(view) || builds != 2 {
180- t.Fatalf("wake did not rebuild: builds=%d", builds)
181- }
182- close(ch)
183- select {
184- case <-done:
185- case <-time.After(time.Second):
186- t.Fatal("forwarder did not stop on close")
187- }
188-}
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-
207-func TestNodeRectAndFrameSize(t *testing.T) {
208- r := newRuntime()
209- r.width, r.height = 400, 300
210- var w, h float64
211- r.Build(func(f *Frame) {
212- w, h = f.Size()
213- stack := f.Root().Stack()
214- stack.Box(Key("a")).Rect(10, 20, 30, 40)
215- stack.Box(Key("b")).Rect(50.5, 60.25, 70, 80)
216- })
217- if w != 400 || h != 300 {
218- t.Fatalf("size %v x %v", w, h)
219- }
220- kids := r.output.Tree.Children[0].Children
221- if len(kids) != 2 {
222- t.Fatalf("children %d", len(kids))
223- }
224- if kids[0].Border != (layout.Rect{X: 10, Y: 20, W: 30, H: 40}) || kids[1].Border != (layout.Rect{X: 50.5, Y: 60.25, W: 70, H: 80}) {
225- t.Fatalf("rects %+v %+v", kids[0].Border, kids[1].Border)
226- }
227- // Hit testing follows the rect geometry.
228- if id := layout.Hit(r.output.Tree, 12, 22); id != kids[0].ID {
229- t.Fatalf("hit %q want %q", id, kids[0].ID)
230- }
231-}
232-
233-// Raw input observation must not replace normal control routing.
234-func TestRawHookDoesNotBlockControls(t *testing.T) {
235- r := newRuntime()
236- catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
237- if err != nil {
238- t.Fatal(err)
239- }
240- r.setTextEngine(text.NewEngine(catalog))
241- r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px} button {height:20px}`))
242- activated := 0
243- view := shell(r, &activated)
244- r.Build(view)
245- button := r.output.Tree.Children[1]
246- seen := 0
247- h := &inputHooks{onInput: func(ev InputEvent) bool {
248- if ev.Kind == InputPointerPress || ev.Kind == InputPointerRelease {
249- seen++
250- }
251- return false
252- }}
253- for _, in := range []wayland.Input{
254- {Kind: "motion", X: 1, Y: button.Border.Y + 1},
255- {Kind: "press", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
256- {Kind: "release", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
257- } {
258- h.input(in)
259- r.routeInput(in)
260- }
261- r.Build(view)
262- if seen != 2 || activated != 1 {
263- t.Fatalf("seen %d activated %d", seen, activated)
264- }
265-}
266-
267-func TestSurfaceHookOptionIsStored(t *testing.T) {
268- var cfg windowConfig
269- OnSurface(nil).window(&cfg)
270- if cfg.onSurface != nil {
271- t.Fatal("nil hook stored")
272- }
273- called := false
274- OnSurface(func(context.Context, WaylandSurface) error { called = true; return nil }).window(&cfg)
275- if cfg.onSurface == nil || cfg.onSurface(context.Background(), WaylandSurface{}) != nil || !called {
276- t.Fatal("hook not stored")
277- }
278-}
279-
280-// testDisplay is a real wlturbo display over a unix socket with no compositor.
281-func testDisplay(t *testing.T) *wl.Display {
282- t.Helper()
283- l, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
284- if err != nil {
285- t.Fatal(err)
286- }
287- defer l.Close()
288- accepted := make(chan net.Conn, 1)
289- go func() {
290- c, _ := l.Accept()
291- accepted <- c
292- }()
293- c, err := net.Dial("unix", l.Addr().String())
294- if err != nil {
295- t.Fatal(err)
296- }
297- server := <-accepted
298- t.Cleanup(func() { server.Close() })
299- d, err := wlturbo.ConnectFromConn(c)
300- if err != nil {
301- t.Fatal(err)
302- }
303- t.Cleanup(func() { d.Close() })
304- return d
305-}
306-
307-func TestStartupHookErrorsAndCancellation(t *testing.T) {
308- boom := errors.New("boom")
309- // A plain error passes through and does not need a display.
310- if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return boom }, WaylandSurface{}); !errors.Is(err, boom) {
311- t.Fatalf("error: %v", err)
312- }
313- if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return nil }, WaylandSurface{}); err != nil {
314- t.Fatalf("nil: %v", err)
315- }
316- // A hook that observes cancellation reports ctx.Err joined with its own error.
317- display := testDisplay(t)
318- ctx, cancel := context.WithCancel(context.Background())
319- err := startupHook(ctx, func(ctx context.Context, _ WaylandSurface) error {
320- cancel()
321- <-ctx.Done()
322- return boom
323- }, WaylandSurface{Display: display})
324- if !errors.Is(err, context.Canceled) || !errors.Is(err, boom) || !display.Closed() {
325- t.Fatalf("cancel: %v", err)
326- }
327- // Cancelling without a hook error still reports the cancellation.
328- ctx, cancel = context.WithCancel(context.Background())
329- err = startupHook(ctx, func(context.Context, WaylandSurface) error { cancel(); return nil }, WaylandSurface{Display: testDisplay(t)})
330- if !errors.Is(err, context.Canceled) {
331- t.Fatalf("cancel without error: %v", err)
332- }
333-}
334-
335-func TestFrameSetInputRects(t *testing.T) {
336- r := newRuntime()
337- var setErr error
338- rects := []Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}
339- r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects(rects) })
340- if setErr != nil {
341- t.Fatal(setErr)
342- }
343- rects[0].Width = 99 // caller reuse must not affect the staged copy
344- var got [][]wayland.Rect
345- set := func(rs []wayland.Rect) error { got = append(got, append([]wayland.Rect{}, rs...)); return nil }
346- if err := r.applyInputRects(set); err != nil {
347- t.Fatal(err)
348- }
349- if len(got) != 1 || len(got[0]) != 2 || got[0][0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
350- t.Fatalf("applied %+v", got)
351- }
352- // Nothing staged: the platform is not called again.
353- if err := r.applyInputRects(set); err != nil || len(got) != 1 {
354- t.Fatalf("restaged: %v %+v", err, got)
355- }
356- // nil restores the full surface; empty non-nil is click-through.
357- var nilSeen, emptySeen bool
358- r.Redraw()
359- r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects(nil) })
360- _ = r.applyInputRects(func(rs []wayland.Rect) error { nilSeen = rs == nil; return nil })
361- r.Redraw()
362- r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{}) })
363- _ = r.applyInputRects(func(rs []wayland.Rect) error { emptySeen = rs != nil && len(rs) == 0; return nil })
364- if !nilSeen || !emptySeen {
365- t.Fatalf("nil %v empty %v", nilSeen, emptySeen)
366- }
367- // Invalid rects are rejected in the frame call and stage nothing.
368- r.Redraw()
369- for _, bad := range []Rect{{0, 0, 0, 5}, {0, 0, 5, -1}, {math.MaxInt32, 0, 5, 5}} {
370- r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects([]Rect{bad}) })
371- if setErr == nil || r.inputStaged {
372- t.Fatalf("accepted %+v", bad)
373- }
374- r.Redraw()
375- }
376- // Steady-state staging and apply do not allocate once buffers exist.
377- r.Build(func(f *Frame) {
378- f.Root()
379- if n := testing.AllocsPerRun(50, func() { _ = f.SetInputRects(rects) }); n != 0 {
380- t.Fatalf("SetInputRects allocs %v", n)
381- }
382- })
383- noop := func([]wayland.Rect) error { return nil }
384- r.inputStaged = true
385- if n := testing.AllocsPerRun(50, func() { r.inputStaged = true; _ = r.applyInputRects(noop) }); n != 0 {
386- t.Fatalf("applyInputRects allocs %v", n)
387- }
388-}
389-
390-// A failed stage keeps the region pending so the next frame retries it.
391-func TestStagedInputRectsRetainedOnFailure(t *testing.T) {
392- r := newRuntime()
393- r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{{1, 2, 3, 4}}) })
394- boom := errors.New("stage failed")
395- if err := r.applyInputRects(func([]wayland.Rect) error { return boom }); !errors.Is(err, boom) || !r.inputStaged {
396- t.Fatalf("failure: %v staged=%v", err, r.inputStaged)
397- }
398- var got []wayland.Rect
399- if err := r.applyInputRects(func(rs []wayland.Rect) error { got = append(got, rs...); return nil }); err != nil || r.inputStaged {
400- t.Fatalf("retry: %v staged=%v", err, r.inputStaged)
401- }
402- if len(got) != 1 || got[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
403- t.Fatalf("retried region %+v", got)
404- }
405- // A later frame that does not call SetInputRects stages nothing new.
406- r.Redraw()
407- r.Build(func(f *Frame) { f.Root() })
408- called := false
409- _ = r.applyInputRects(func([]wayland.Rect) error { called = true; return nil })
410- if called {
411- t.Fatal("unchanged frame restaged the region")
412- }
413-}
414-
415-func TestNodeRectOutsideStackIsIgnored(t *testing.T) {
416- r := newRuntime()
417- r.width, r.height = 200, 100
418- r.Build(func(f *Frame) {
419- col := f.Root().Column()
420- col.Box(Key("a"), Inline("height:10px")).Rect(50, 50, 5, 5)
421- col.Box(Key("b"), Inline("height:10px"))
422- })
423- kids := r.output.Tree.Children[0].Children
424- if kids[0].Border.X != 0 || kids[0].Border.H != 10 || kids[1].Border.Y != 10 {
425- t.Fatalf("Rect changed normal flow: %+v %+v", kids[0].Border, kids[1].Border)
426- }
427-}
428-
429-// The absolute geometry path adds no allocations over the same tree without it.
430-func TestNodeRectAddsNoAllocations(t *testing.T) {
431- build := func(withRect bool) func() {
432- r := newRuntime()
433- r.width, r.height = 640, 480
434- view := func(f *Frame) {
435- st := f.Root().Stack()
436- for i := 0; i < 32; i++ {
437- b := st.Box(Key(strconv.Itoa(i)), Inline("background:#fff"))
438- if withRect {
439- b.Rect(float64(i), float64(i), 20, 20)
440- }
441- }
442- }
443- r.Build(view)
444- return func() { r.Redraw(); r.Build(view) }
445- }
446- plain := testing.AllocsPerRun(20, build(false))
447- rect := testing.AllocsPerRun(20, build(true))
448- if rect > plain {
449- t.Fatalf("Rect added allocations: %v vs %v", rect, plain)
450- }
451-}
A internal/ui/inputrects.go +30 -0
@@ -0,0 +1,30 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "math"
6+)
7+
8+// Rect is an integer logical-pixel rectangle in surface coordinates.
9+type Rect struct{ X, Y, Width, Height int32 }
10+
11+// SetInputRects stages the input region for the surface: pointer input outside
12+// the rectangles passes through. Renderer reports it in Output.InputRects when
13+// it changes. nil restores the whole surface, and a non-nil empty slice makes
14+// the surface click-through. Rects need positive size. The slice is copied, so
15+// callers may reuse it; an unchanged region costs nothing.
16+func (f *Frame) SetInputRects(rects []Rect) error {
17+ if !f.active {
18+ panic("nefergui: SetInputRects outside frame")
19+ }
20+ for i, r := range rects {
21+ if r.Width <= 0 || r.Height <= 0 || int64(r.X)+int64(r.Width) > math.MaxInt32 || int64(r.Y)+int64(r.Height) > math.MaxInt32 {
22+ return fmt.Errorf("nefergui: input rect %d %+v is empty or overflows", i, r)
23+ }
24+ }
25+ o := f.owner
26+ o.inputNil = rects == nil
27+ o.inputRects = append(o.inputRects[:0], rects...)
28+ o.inputStaged = true
29+ return nil
30+}
M internal/ui/interaction_test.go +4 -5
@@ -6,7 +6,6 @@ import (
66 "testing"
77
88 "github.com/bnema/nefergui/internal/css"
9- "github.com/bnema/nefergui/internal/platform/wayland"
109 "github.com/bnema/nefergui/internal/text"
1110 )
1211
@@ -256,13 +255,13 @@ func TestCursorFollowsHoverAndCapture(t *testing.T) {
256255 }
257256 for _, c := range []struct {
258257 y float64
259- want uint32
260- }{{2, wayland.CursorPointer}, {22, wayland.CursorText}, {42, wayland.CursorNotAllowed}, {62, wayland.CursorDefault}} {
261- if got := cursorShape(at("motion", c.y)); got != c.want {
258+ want Cursor
259+ }{{2, CursorPointer}, {22, CursorText}, {42, CursorNotAllowed}, {62, CursorDefault}} {
260+ if got := cursorOf(at("motion", c.y)); got != c.want {
262261 t.Errorf("y=%v shape %v, want %v", c.y, got, c.want)
263262 }
264263 }
265- if cursorShape(css.KeywordAuto) != cursorShape(css.KeywordDefault) {
264+ if cursorOf(css.KeywordAuto) != cursorOf(css.KeywordDefault) {
266265 t.Error("auto must map to the default shape")
267266 }
268267 // Press on the input, drag over the button: the input keeps its cursor.
D internal/ui/layer.go +0 -134
@@ -1,134 +0,0 @@
1-package ui
2-
3-import (
4- "fmt"
5- "slices"
6-
7- "github.com/bnema/nefergui/internal/platform/wayland"
8-)
9-
10-// LayerLevel is the wlr-layer-shell stacking layer. The zero value is invalid,
11-// so a surface never silently lands below every window.
12-type LayerLevel uint8
13-
14-const (
15- LayerBackground LayerLevel = iota + 1
16- LayerBottom
17- LayerTop
18- LayerOverlay
19-)
20-
21-// Anchor is a bit set of output edges a layer surface attaches to.
22-type Anchor uint8
23-
24-const (
25- AnchorTop Anchor = 1 << iota
26- AnchorBottom
27- AnchorLeft
28- AnchorRight
29-)
30-
31-const anchorAll = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
32-
33-// KeyboardMode is the layer surface keyboard interactivity. The zero value is
34-// KeyboardNone: the surface never takes keyboard focus. KeyboardOnDemand needs
35-// layer-shell version 4.
36-type KeyboardMode uint8
37-
38-const (
39- KeyboardNone KeyboardMode = iota
40- KeyboardExclusive
41- KeyboardOnDemand
42-)
43-
44-// Rect is an integer logical-pixel rectangle in surface coordinates.
45-type Rect struct{ X, Y, Width, Height int32 }
46-
47-// LayerConfig requests a wlr-layer-shell surface instead of an xdg toplevel.
48-// Size still gives the initial logical size; an axis anchored to both opposite
49-// edges is sized by the compositor and reported through OnResize and Frame.Size.
50-// ExclusiveZone > 0 asks the compositor to reserve that many logical pixels
51-// from an anchored edge (only for one edge, or one edge plus both perpendicular
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.
54-type LayerConfig struct {
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
62- Namespace string // defaults to "nefergui"
63- Level LayerLevel
64- Anchors Anchor
65- Keyboard KeyboardMode
66- ExclusiveZone int32
67- Margin [4]int32 // top, right, bottom, left
68- // InputRects, when non-nil, restricts pointer input to these rectangles.
69- // An empty non-nil slice makes the surface fully click-through. Rects need
70- // positive width and height.
71- InputRects []Rect
72-}
73-
74-// surfaceOptions lowers the config to platform options and validates it.
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- }
79- l := wayland.LayerOptions{Output: c.Output, Namespace: c.Namespace, ExclusiveZone: c.ExclusiveZone, Margin: c.Margin}
80- switch c.Level {
81- case LayerBackground:
82- l.Layer = wayland.LayerBackground
83- case LayerBottom:
84- l.Layer = wayland.LayerBottom
85- case LayerTop:
86- l.Layer = wayland.LayerTop
87- case LayerOverlay:
88- l.Layer = wayland.LayerOverlay
89- default:
90- return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid layer level %d", c.Level)
91- }
92- if c.Anchors&^anchorAll != 0 {
93- return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid layer anchors %#x", uint8(c.Anchors))
94- }
95- if c.Anchors&AnchorTop != 0 {
96- l.Anchor |= wayland.AnchorTop
97- }
98- if c.Anchors&AnchorBottom != 0 {
99- l.Anchor |= wayland.AnchorBottom
100- }
101- if c.Anchors&AnchorLeft != 0 {
102- l.Anchor |= wayland.AnchorLeft
103- }
104- if c.Anchors&AnchorRight != 0 {
105- l.Anchor |= wayland.AnchorRight
106- }
107- switch c.Keyboard {
108- case KeyboardNone:
109- l.Keyboard = wayland.KeyboardNone
110- case KeyboardExclusive:
111- l.Keyboard = wayland.KeyboardExclusive
112- case KeyboardOnDemand:
113- l.Keyboard = wayland.KeyboardOnDemand
114- default:
115- return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: invalid keyboard mode %d", c.Keyboard)
116- }
117- o := wayland.SurfaceOptions{Layer: &l}
118- if c.InputRects != nil {
119- o.InputRects = make([]wayland.Rect, len(c.InputRects))
120- for i, r := range c.InputRects {
121- o.InputRects[i] = wayland.Rect{X: r.X, Y: r.Y, W: r.Width, H: r.Height}
122- }
123- }
124- if err := o.Validate(); err != nil {
125- return wayland.SurfaceOptions{}, fmt.Errorf("nefergui: layer: %w", err)
126- }
127- return o, nil
128-}
129-
130-// Layer selects a wlr-layer-shell surface for the window.
131-func Layer(c LayerConfig) WindowOption {
132- c.InputRects = slices.Clone(c.InputRects)
133- return windowOption(func(w *windowConfig) { w.layer = &c })
134-}
D internal/ui/lock.go +0 -594
@@ -1,594 +0,0 @@
1-package ui
2-
3-import (
4- "cmp"
5- "context"
6- "errors"
7- "fmt"
8- "os"
9- "slices"
10- "strings"
11- "time"
12- "unicode/utf8"
13-
14- "github.com/bnema/nefergui/internal/css"
15- "github.com/bnema/nefergui/internal/keyboard"
16- "github.com/bnema/nefergui/internal/platform/wayland"
17- "github.com/bnema/nefergui/internal/presentation/session"
18- "github.com/bnema/nefergui/internal/render"
19- "github.com/bnema/nefergui/internal/text"
20-)
21-
22-// ErrLockFinished reports that the compositor refused the session lock or
23-// ended it (ext_session_lock_v1.finished). NeferGUI never unlocks in response.
24-var ErrLockFinished = errors.New("nefergui: session lock finished by the compositor")
25-
26-// SecretBuffer is a caller-owned fixed-capacity byte buffer for typed secret
27-// input. Typed bytes never become a Go string and are never stored in the
28-// model, tree or render text; the view only sees Len. Bytes beyond the
29-// capacity are dropped. It is used from the lock owner loop only.
30-type SecretBuffer struct {
31- buf []byte
32- n int
33- runes int
34-}
35-
36-// NewSecretBuffer allocates a buffer holding at most capacity bytes (1..4096).
37-func NewSecretBuffer(capacity int) *SecretBuffer {
38- if capacity < 1 || capacity > 4096 {
39- panic("nefergui: secret buffer capacity must be between 1 and 4096")
40- }
41- return &SecretBuffer{buf: make([]byte, capacity)}
42-}
43-
44-// Len is the number of typed code points (the mask count).
45-func (b *SecretBuffer) Len() int { return b.runes }
46-
47-// Bytes returns the typed bytes. The slice aliases the buffer: do not retain
48-// it past the next edit or Wipe, and never convert it to a string.
49-func (b *SecretBuffer) Bytes() []byte { return b.buf[:b.n:b.n] }
50-
51-// Wipe zeroes the buffer. The caller may call it at any time on the owner loop.
52-func (b *SecretBuffer) Wipe() {
53- clear(b.buf)
54- b.n, b.runes = 0, 0
55-}
56-
57-func (b *SecretBuffer) append(p []byte) {
58- if len(p) == 0 || len(p) > len(b.buf)-b.n || !utf8.Valid(p) {
59- return
60- }
61- b.n += copy(b.buf[b.n:], p)
62- b.runes += utf8.RuneCount(p)
63-}
64-
65-func (b *SecretBuffer) backspace() {
66- if b.n == 0 {
67- return
68- }
69- _, size := utf8.DecodeLastRune(b.buf[:b.n])
70- clear(b.buf[b.n-size : b.n])
71- b.n -= size
72- b.runes--
73-}
74-
75-// LockStatus is an opaque caller-driven state shown by the view, for example
76-// while an authentication attempt runs. NeferGUI attaches no meaning to it.
77-type LockStatus uint8
78-
79-const (
80- LockIdle LockStatus = iota // ready for input
81- LockBusy // an attempt is being verified
82- LockFailed // the last attempt was rejected
83-)
84-
85-// LockState is what the lock view may know: never the secret itself.
86-type LockState struct {
87- Mask int // typed code points, for a mask of that many bullets
88- Locked bool // the compositor confirmed the lock (locked event)
89- Status LockStatus // latest value received on LockConfig.Status
90- Width, Height int // logical size of the surface hosting the view
91-}
92-
93-// Bullets returns Mask bullet characters.
94-func (s LockState) Bullets() string { return strings.Repeat("•", s.Mask) }
95-
96-// LockConfig configures RunLock.
97-type LockConfig struct {
98- // Display is the Wayland socket name; empty uses the environment.
99- Display string
100- // Styles is an optional author stylesheet path, as for the Styles option.
101- Styles string
102- // Secret receives typed input. Required.
103- Secret *SecretBuffer
104- // View builds the content of the one output that hosts it (the output whose
105- // surface has keyboard focus, initially the first). Required. Other outputs
106- // show plain opaque black.
107- View func(*Frame, LockState)
108- // OnLocked runs once on the owner loop when the compositor sends locked.
109- OnLocked func()
110- // OnSubmit runs on the owner loop when Enter is pressed after locked. It
111- // must not block: verification is asynchronous. secret aliases Secret's
112- // memory and is valid only during the call; it is wiped right after it
113- // returns, so the callee copies or consumes it before returning.
114- OnSubmit func(secret []byte)
115- // Unlock, when it receives a value, asks RunLock to send
116- // unlock_and_destroy, roundtrip and return nil. This is the only unlock
117- // path: it is the caller's explicit request after its own verification. A
118- // request received before the locked event is held until locked; closing
119- // the channel is not a request. A nil channel never unlocks.
120- Unlock <-chan struct{}
121- // Status delivers the state shown as LockState.Status; each received value
122- // schedules a redraw. Optional.
123- Status <-chan LockStatus
124- // Wake requests a redraw for each received value.
125- Wake <-chan struct{}
126- // OnOutputError reports an output added after acquisition that could not
127- // get a lock surface; the compositor keeps it black. Optional, owner loop.
128- OnOutputError func(output string, err error)
129-}
130-
131-type lockSurface struct {
132- w *wayland.Window
133- output uint32
134- s *session.Session
135- p *session.Pump
136-}
137-
138-// RunLock acquires ext-session-lock on one connection and shows opaque black on
139-// every output through the regular Vulkan/DMA-BUF/explicit-sync path. Outputs
140-// added or removed later gain or lose a lock surface. Keyboard input feeds
141-// cfg.Secret only through the secret keyboard path. RunLock returns nil after
142-// an unlock requested through cfg.Unlock, ErrLockFinished (wrapped) when the
143-// compositor ends or refuses the lock, or the first failure; it never unlocks
144-// on failure or cancellation (closing the connection keeps the session locked).
145-func RunLock(ctx context.Context, cfg LockConfig) (err error) {
146- if cfg.Secret == nil || cfg.View == nil {
147- return errors.New("nefergui: lock needs Secret and View")
148- }
149- if err = ctx.Err(); err != nil {
150- return err
151- }
152- defer cfg.Secret.Wipe() // also on panic and every early return
153- ctx, stop := context.WithCancel(ctx)
154- defer stop() // ends the wake forwarder with RunLock
155- r := newRuntime()
156- if cfg.Styles != "" {
157- data, e := os.ReadFile(cfg.Styles)
158- if e != nil {
159- return fmt.Errorf("nefergui: styles: %w", e)
160- }
161- r.styles = css.Compile(css.UA(), css.Parse(string(data)))
162- }
163- catalog, err := text.Load(text.SystemSource{})
164- if err != nil {
165- return fmt.Errorf("nefergui: fonts: %w", err)
166- }
167- if len(catalog.Faces) == 0 {
168- return errors.New("nefergui: no usable fonts")
169- }
170- r.setTextEngine(text.NewEngine(catalog))
171- conn, err := wayland.ConnectConnection(ctx, cfg.Display)
172- if err != nil {
173- return fmt.Errorf("nefergui: open platform: %w", err)
174- }
175- l := &locker{cfg: cfg, r: r, conn: conn, ctx: ctx, surfaces: map[*wayland.Window]*lockSurface{}, pending: map[*wayland.Window]pendingLock{}, covered: map[uint32]bool{}, initial: map[uint32]bool{}, gpuWake: make(chan struct{}, 1), scratch: make([]byte, 128)}
176- defer func() {
177- l.closeAll()
178- clear(l.scratch)
179- if l.kb != nil {
180- l.kb.Close()
181- }
182- err = errors.Join(err, conn.Close())
183- }()
184- if l.kb, err = keyboard.New(""); err != nil {
185- return err
186- }
187- l.kb.Focus(false)
188- if err = conn.AcquireLock(); err != nil {
189- return err
190- }
191- // Surfaces for the outputs present now are created before the reader starts
192- // so the compositor can see them while it decides to send locked.
193- if err = l.reconcile(true); err != nil {
194- return err
195- }
196- events := conn.StartReader()
197- // Lock and output events that arrived while the first surfaces were set up
198- // were kept for us: locked must enable unlock, finished must fail the run.
199- for _, ev := range conn.TakeHeld() {
200- if err = l.handle(ev); err != nil {
201- return err
202- }
203- }
204- if cfg.Wake != nil {
205- go r.forwardWake(ctx, cfg.Wake)
206- }
207- return l.loop(events)
208-}
209-
210-// pendingLock is a created lock surface and the output it covers.
211-type pendingLock struct {
212- output uint32
213- name string // connector name, for error reports
214-}
215-
216-type locker struct {
217- cfg LockConfig
218- r *runtime
219- conn *wayland.Connection
220- ctx context.Context
221- kb *keyboard.Keyboard
222- scratch []byte
223- surfaces map[*wayland.Window]*lockSurface
224- pending map[*wayland.Window]pendingLock // created, not yet configured with feedback
225- covered map[uint32]bool // output globals with a surface or a pending one
226- initial map[uint32]bool // outputs present at acquisition: their failures abort
227- host *lockSurface
228- locked bool
229- status LockStatus
230- unlock bool // the caller asked for unlock; honoured once locked
231- gpuWake chan struct{}
232-}
233-
234-func (l *locker) loop(events <-chan wayland.Event) error {
235- for {
236- if err := l.promote(); err != nil {
237- return err
238- }
239- retry := false
240- for _, ls := range l.surfaces {
241- again, err := ls.p.Step()
242- if err != nil {
243- return err
244- }
245- retry = retry || again
246- }
247- var retryC, repeatC <-chan time.Time
248- if retry {
249- retryC = time.After(5 * time.Millisecond)
250- }
251- if at, ok := l.kb.NextRepeat(); ok {
252- repeatC = time.After(time.Until(at))
253- }
254- select {
255- case <-l.ctx.Done():
256- return l.ctx.Err()
257- case <-l.r.wake:
258- l.invalidateHost()
259- case _, ok := <-l.cfg.Unlock:
260- if ok {
261- l.unlock = true
262- } else {
263- l.cfg.Unlock = nil // closing is not a request
264- }
265- case st, ok := <-l.cfg.Status:
266- if ok {
267- l.status = st
268- l.secretChanged()
269- } else {
270- l.cfg.Status = nil
271- }
272- case <-l.gpuWake:
273- case <-retryC:
274- case <-repeatC:
275- if _, err := l.kb.TickSecret(time.Now(), l.scratch, l.consume); err != nil {
276- return err
277- }
278- l.secretChanged()
279- case ev := <-events:
280- if err := l.handle(ev); err != nil {
281- return err
282- }
283- }
284- if l.shouldUnlock() {
285- return l.finishUnlock()
286- }
287- }
288-}
289-
290-func (l *locker) handle(ev wayland.Event) error {
291- switch ev.Kind {
292- case wayland.BufferRelease:
293- if ls := l.surfaces[ev.Window]; ls != nil {
294- return ls.p.BufferReleased(ev.BufferID)
295- }
296- return nil
297- case wayland.OutputRemoved:
298- l.removeOutput(ev.Output.Global)
299- }
300- if err := l.conn.Apply(ev); err != nil {
301- return err
302- }
303- switch ev.Kind {
304- case wayland.LockAcquired:
305- l.locked = true
306- l.invalidateHost()
307- if l.cfg.OnLocked != nil {
308- l.cfg.OnLocked()
309- }
310- case wayland.LockFinished:
311- return ErrLockFinished
312- case wayland.OutputGlobal, wayland.OutputNamed, wayland.OutputAdded:
313- if err := l.reconcile(false); err != nil {
314- return err
315- }
316- }
317- return l.drainInput()
318-}
319-
320-// reconcile gives every named output without one a lock surface. A failure on
321-// an initial output aborts the lock. A hotplugged output that cannot get a
322-// surface is logged and left alone: the compositor keeps it black regardless.
323-func (l *locker) reconcile(initial bool) error {
324- for _, o := range l.conn.Outputs() {
325- if l.covered[o.Global] {
326- continue
327- }
328- l.covered[o.Global] = true // also on failure: never retried for this lifetime
329- w, err := l.conn.NewWindow(l.ctx, 1, 1, false, wayland.SurfaceOptions{Lock: &wayland.LockOptions{Output: o.Global}})
330- if err != nil {
331- if err = l.outputFailure(initial, "lock surface", o.Name, err); err != nil {
332- return err
333- }
334- continue
335- }
336- if initial {
337- l.initial[o.Global] = true
338- }
339- l.pending[w] = pendingLock{output: o.Global, name: o.Name}
340- }
341- return nil
342-}
343-
344-// promote opens the Vulkan session of every window whose configure was
345-// acknowledged and whose dmabuf feedback round is complete, in ascending
346-// output global order so the first host is deterministic.
347-func (l *locker) promote() error {
348- ready := make([]*wayland.Window, 0, len(l.pending))
349- for w := range l.pending {
350- if w.Configured && w.FeedbackDone {
351- ready = append(ready, w)
352- }
353- }
354- slices.SortFunc(ready, func(a, b *wayland.Window) int { return cmp.Compare(l.pending[a].output, l.pending[b].output) })
355- for _, w := range ready {
356- pl := l.pending[w]
357- output := pl.output
358- delete(l.pending, w)
359- ls, err := l.open(w, output) // closes w on failure
360- if err != nil {
361- if err = l.outputFailure(l.initial[output], "lock render path", pl.name, err); err != nil {
362- return err
363- }
364- continue
365- }
366- l.surfaces[w] = ls
367- if l.host == nil {
368- l.setHost(ls)
369- }
370- }
371- return nil
372-}
373-
374-// outputFailure applies the per-output failure policy. A refused or ended lock
375-// always aborts (as ErrLockFinished). Otherwise only outputs present at
376-// acquisition abort; a hotplugged output is skipped, since the
377-// compositor keeps it black without our surface; OnOutputError reports it.
378-func (l *locker) outputFailure(initial bool, what, output string, err error) error {
379- if abort := outputFailure(initial, what, output, err); abort != nil {
380- return abort
381- }
382- if l.cfg.OnOutputError != nil {
383- l.cfg.OnOutputError(output, fmt.Errorf("%s: %w", what, err))
384- }
385- return nil
386-}
387-
388-func outputFailure(initial bool, what, output string, err error) error {
389- if errors.Is(err, wayland.ErrLockFinished) {
390- return fmt.Errorf("%w: %w", ErrLockFinished, err)
391- }
392- if initial {
393- return fmt.Errorf("nefergui: %s for output %s: %w", what, output, err)
394- }
395- return nil
396-}
397-
398-// open builds the Vulkan session and frame pump of a configured lock window.
399-func (l *locker) open(w *wayland.Window, output uint32) (*lockSurface, error) {
400- s, err := session.OpenWindow(w, false)
401- if err != nil {
402- return nil, err
403- }
404- ls := &lockSurface{w: w, output: output, s: s}
405- if ls.p, err = s.NewPump(l.ctx, l.gpuWake, func() (render.Frame, bool, error) { return l.draw(ls) }); err != nil {
406- _ = s.Close()
407- return nil, err
408- }
409- return ls, nil
410-}
411-
412-func (l *locker) removeOutput(global uint32) {
413- delete(l.covered, global)
414- for w, pl := range l.pending {
415- if pl.output == global {
416- delete(l.pending, w)
417- _ = w.Close()
418- }
419- }
420- for w, ls := range l.surfaces {
421- if ls.output != global {
422- continue
423- }
424- delete(l.surfaces, w)
425- ls.close()
426- if l.host == ls {
427- l.host = nil
428- var next *lockSurface
429- for _, o := range l.surfaces {
430- if next == nil || o.output < next.output {
431- next = o
432- }
433- }
434- if next != nil {
435- l.setHost(next)
436- }
437- }
438- }
439-}
440-
441-func (ls *lockSurface) close() {
442- ls.p.Close()
443- _ = ls.s.Close()
444-}
445-
446-func (l *locker) closeAll() {
447- for w, ls := range l.surfaces {
448- delete(l.surfaces, w)
449- ls.close()
450- }
451- for w := range l.pending {
452- delete(l.pending, w)
453- _ = w.Close()
454- }
455-}
456-
457-func (l *locker) setHost(ls *lockSurface) {
458- if l.host == ls {
459- return
460- }
461- old := l.host
462- l.host = ls
463- if old != nil {
464- old.p.Invalidate()
465- }
466- l.r.Redraw()
467- ls.p.Invalidate()
468-}
469-
470-func (l *locker) invalidateHost() {
471- if l.host != nil {
472- l.host.p.Invalidate()
473- }
474-}
475-
476-// draw shows the view on the host output and plain black elsewhere. An empty
477-// frame is the opaque clear color; alpha is not part of the XRGB buffer.
478-func (l *locker) draw(ls *lockSurface) (render.Frame, bool, error) {
479- if l.host != ls {
480- return render.Frame{}, true, nil
481- }
482- w := ls.w
483- l.r.route(platformInput{Kind: "resize", Width: float64(w.Width), Height: float64(w.Height), Scale: w.Scale})
484- state := LockState{Mask: l.cfg.Secret.Len(), Locked: l.locked, Status: l.status, Width: int(w.Width), Height: int(w.Height)}
485- if !l.r.Build(func(f *Frame) { l.cfg.View(f, state) }) {
486- return render.Frame{}, false, nil
487- }
488- pw, ph, err := w.PhysicalSize()
489- if err != nil {
490- return render.Frame{}, false, err
491- }
492- frame, err := ls.s.Preparer.Prepare(l.r.output.Display, w.Scale, int(pw), int(ph))
493- return frame, true, err
494-}
495-
496-// drainInput feeds raw keyboard protocol events from every lock window to the
497-// secret keyboard. Nothing here creates a string or reaches the UI runtime.
498-func (l *locker) drainInput() error {
499- take := func(w *wayland.Window) error {
500- events := w.TakeInputEvents()
501- for i, e := range events {
502- if err := l.input(w, e); err != nil {
503- for _, rest := range events[i+1:] {
504- wayland.CloseEventFD(rest) // never leak keymap FDs
505- }
506- return err
507- }
508- }
509- return nil
510- }
511- for w := range l.surfaces {
512- if err := take(w); err != nil {
513- return err
514- }
515- }
516- for w := range l.pending {
517- if err := take(w); err != nil {
518- return err
519- }
520- }
521- return nil
522-}
523-
524-func (l *locker) input(w *wayland.Window, e wayland.Event) error {
525- switch e.Kind {
526- case wayland.InputKeymap:
527- if e.FD < 0 {
528- return errors.New("nefergui: missing keymap fd")
529- }
530- if e.Format != 1 || e.Size == 0 || e.Size > 16<<20 {
531- wayland.CloseEventFD(e)
532- return nil
533- }
534- return l.kb.ReplaceFD(e.FD, int(e.Size))
535- case wayland.InputModifiers:
536- _ = l.kb.Mask(e.Mods[0], e.Mods[1], e.Mods[2], 0, 0, e.Mods[3]) // no keymap yet: ignore
537- case wayland.InputRepeatInfo:
538- l.kb.RepeatInfo(int(e.Rate), time.Duration(e.Delay)*time.Millisecond)
539- case wayland.InputFocusIn:
540- l.kb.Focus(true)
541- if ls := l.surfaces[w]; ls != nil {
542- l.setHost(ls)
543- }
544- case wayland.InputFocusOut:
545- l.kb.ClearSecret()
546- l.kb.Focus(false)
547- case wayland.InputKey:
548- if err := l.kb.EventSecret(e.Code, e.Pressed, time.Now(), l.scratch, l.consume); err != nil {
549- return err
550- }
551- l.secretChanged()
552- }
553- return nil
554-}
555-
556-func (l *locker) secretChanged() {
557- l.r.Redraw()
558- l.invalidateHost()
559-}
560-
561-// consume applies one secret key: bytes append, Backspace removes a code
562-// point, Escape and Ctrl+U clear, Enter submits.
563-func (l *locker) consume(k keyboard.SecretKey, chunk []byte) {
564- if !k.Pressed {
565- return
566- }
567- b := l.cfg.Secret
568- switch k.Kind {
569- case keyboard.SecretBytes:
570- b.append(chunk)
571- case keyboard.SecretBackspace:
572- b.backspace()
573- case keyboard.SecretEscape, keyboard.SecretClear:
574- b.Wipe()
575- case keyboard.SecretReturn:
576- // Before the locked event Enter only clears. Submitting never unlocks.
577- defer b.Wipe() // even if OnSubmit panics
578- if l.locked && l.cfg.OnSubmit != nil && b.Len() > 0 {
579- l.cfg.OnSubmit(b.Bytes())
580- }
581- }
582-}
583-
584-// shouldUnlock reports that the caller asked for unlock and the lock is held.
585-func (l *locker) shouldUnlock() bool { return l.unlock && l.locked }
586-
587-// finishUnlock is reached only after the caller sent on cfg.Unlock.
588-func (l *locker) finishUnlock() error {
589- if err := l.conn.UnlockAndDestroy(); err != nil { // includes the display roundtrip
590- return err
591- }
592- l.closeAll()
593- return nil
594-}
D internal/ui/lock_test.go +0 -281
@@ -1,281 +0,0 @@
1-//go:build linux
2-
3-package ui
4-
5-import (
6- "bytes"
7- "context"
8- "errors"
9- "fmt"
10- "os"
11- "path/filepath"
12- "regexp"
13- "strings"
14- "testing"
15- "time"
16-
17- "github.com/bnema/nefergui/internal/harness"
18- "github.com/bnema/nefergui/internal/keyboard"
19- "github.com/bnema/nefergui/internal/platform/wayland"
20-)
21-
22-func TestSecretBufferEditing(t *testing.T) {
23- b := NewSecretBuffer(8)
24- b.append([]byte("a"))
25- b.append([]byte("é")) // two bytes, one code point
26- b.append([]byte("z"))
27- if b.Len() != 3 || string(b.Bytes()) != "aéz" {
28- t.Fatalf("len=%d", b.Len())
29- }
30- b.append([]byte{0xff}) // invalid UTF-8 is dropped
31- b.append(bytes.Repeat([]byte("x"), 9))
32- if b.Len() != 3 {
33- t.Fatal("invalid or oversized chunk accepted")
34- }
35- b.backspace()
36- b.backspace() // removes the two-byte code point whole
37- if b.Len() != 1 || !bytes.Equal(b.Bytes(), []byte("a")) {
38- t.Fatalf("backspace: len=%d", b.Len())
39- }
40- backing := b.buf
41- b.Wipe()
42- if b.Len() != 0 || len(b.Bytes()) != 0 || !bytes.Equal(backing, make([]byte, 8)) {
43- t.Fatal("buffer not wiped")
44- }
45- if got := (LockState{Mask: 3}).Bullets(); got != "•••" {
46- t.Fatal(got)
47- }
48-}
49-
50-func TestConsumeSubmitIsAsyncAndNeverUnlocks(t *testing.T) {
51- var got [][]byte
52- secret := NewSecretBuffer(8)
53- l := &locker{cfg: LockConfig{Secret: secret, OnSubmit: func(b []byte) { got = append(got, bytes.Clone(b)) }}}
54- enter := keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true}
55- secret.append([]byte("pw"))
56- l.consume(enter, nil) // before locked: only clears
57- if len(got) != 0 || secret.Len() != 0 || l.unlock {
58- t.Fatalf("submit before locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock)
59- }
60- l.locked = true
61- secret.append([]byte("pw"))
62- l.consume(enter, nil)
63- if len(got) != 1 || string(got[0]) != "pw" || secret.Len() != 0 || l.unlock {
64- t.Fatalf("submit after locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock)
65- }
66- l.consume(enter, nil) // empty buffer submits nothing
67- if len(got) != 1 {
68- t.Fatal("empty submit reached the callback")
69- }
70-}
71-
72-func TestShouldUnlockGate(t *testing.T) {
73- l := &locker{conn: &wayland.Connection{}}
74- if l.shouldUnlock() {
75- t.Fatal("unlock without request")
76- }
77- l.unlock = true // a request held before locked
78- if l.shouldUnlock() {
79- t.Fatal("unlock before locked")
80- }
81- if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil {
82- t.Fatal(err)
83- }
84- if !l.shouldUnlock() {
85- t.Fatal("held request not honoured once locked")
86- }
87- l.unlock = false
88- if l.shouldUnlock() {
89- t.Fatal("unlock without request after locked")
90- }
91-}
92-
93-func TestConsumeWipesWhenOnSubmitPanics(t *testing.T) {
94- secret := NewSecretBuffer(8)
95- l := &locker{locked: true, cfg: LockConfig{Secret: secret, OnSubmit: func([]byte) { panic("boom") }}}
96- secret.append([]byte("pw"))
97- func() {
98- defer func() { _ = recover() }()
99- l.consume(keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true}, nil)
100- }()
101- if secret.Len() != 0 || !bytes.Equal(secret.buf, make([]byte, 8)) {
102- t.Fatal("secret not wiped after OnSubmit panic")
103- }
104-}
105-
106-func TestForwardWakeStopsWithContext(t *testing.T) {
107- ctx, cancel := context.WithCancel(context.Background())
108- done := make(chan struct{})
109- go func() { newRuntime().forwardWake(ctx, make(chan struct{})); close(done) }()
110- cancel()
111- select {
112- case <-done:
113- case <-time.After(5 * time.Second):
114- t.Fatal("forwardWake outlived its context")
115- }
116-}
117-
118-func TestHarnessRunLock(t *testing.T) {
119- if os.Getenv("NEFERGUI_HARNESS") != "1" {
120- t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness")
121- }
122- root := t.TempDir()
123- script := filepath.Join(root, "input.txt")
124- // Enough time for lock + first frame; then a wrong secret, clear, and the right one.
125- if err := os.WriteFile(script, []byte("sleep 4s\ntype wrong\nkey Escape\nsleep 200ms\ntype open\nkey Return\nsleep 1s\n"), 0600); err != nil {
126- t.Fatal(err)
127- }
128- result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(root, "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Input: script, Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockClient$", "-test.v"}})
129- out, _ := os.ReadFile(result.Artifacts["client_stdout"])
130- if code != harness.Pass {
131- errOut, _ := os.ReadFile(result.Artifacts["client_stderr"])
132- t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut)
133- }
134- t.Logf("client output:\n%s", out)
135- // Frames coalesce, so only the shape is asserted: the mask rises, Escape
136- // clears it, and the accepted secret is wiped.
137- for _, want := range []string{"LOCK locked", "LOCK frame", "LOCK submit ok", "LOCK unlocked [0 ", "LOCK wake stopped"} {
138- if !strings.Contains(string(out), want) {
139- t.Fatalf("missing %q in:\n%s", want, out)
140- }
141- }
142- // A mask above zero was drawn before the final wipe to zero.
143- if !regexp.MustCompile(`LOCK unlocked \[0( [0-9]+)* [1-9][0-9]* 0\]`).Match(out) {
144- t.Fatalf("no mask above 0 before the final 0 in:\n%s", out)
145- }
146-}
147-
148-func TestHarnessRunLockClient(t *testing.T) {
149- if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
150- t.Skip("harness child only")
151- }
152- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
153- defer cancel()
154- secret := NewSecretBuffer(64)
155- unlock, status := make(chan struct{}, 1), make(chan LockStatus, 4)
156- frames := 0
157- var maskSeen []int
158- wake := make(chan struct{})
159- err := RunLock(ctx, LockConfig{
160- Secret: secret,
161- Wake: wake,
162- View: func(f *Frame, s LockState) {
163- if frames == 0 {
164- fmt.Println("LOCK frame")
165- }
166- frames++
167- if len(maskSeen) == 0 || maskSeen[len(maskSeen)-1] != s.Mask {
168- maskSeen = append(maskSeen, s.Mask)
169- }
170- col := f.Root().Column()
171- col.Text(s.Bullets(), Key("mask"))
172- },
173- OnLocked: func() { fmt.Println("LOCK locked") },
174- Unlock: unlock,
175- Status: status,
176- OnSubmit: func(b []byte) {
177- // Test-only comparison; real callers hand the bytes to a verifier.
178- if string(b) == "open" {
179- fmt.Println("LOCK submit ok")
180- status <- LockBusy
181- go func() { time.Sleep(300 * time.Millisecond); unlock <- struct{}{} }()
182- }
183- },
184- })
185- if err != nil {
186- t.Fatal(err)
187- }
188- if secret.Len() != 0 {
189- t.Fatal("secret not wiped after submit")
190- }
191- select {
192- case wake <- struct{}{}:
193- t.Fatal("wake forwarder outlived RunLock")
194- case <-time.After(200 * time.Millisecond):
195- fmt.Println("LOCK wake stopped")
196- }
197- fmt.Println("LOCK unlocked", maskSeen)
198-}
199-
200-func TestOutputFailurePolicy(t *testing.T) {
201- boom := errors.New("boom")
202- if err := outputFailure(true, "lock surface", "DP-1", boom); !errors.Is(err, boom) {
203- t.Fatalf("initial output failure must abort: %v", err)
204- }
205- if err := outputFailure(false, "lock surface", "DP-2", boom); err != nil {
206- t.Fatalf("hotplug failure must be skipped: %v", err)
207- }
208- var reported []string
209- l := &locker{cfg: LockConfig{OnOutputError: func(o string, err error) {
210- if errors.Is(err, boom) {
211- reported = append(reported, o)
212- }
213- }}}
214- if err := l.outputFailure(false, "lock surface", "DP-4", boom); err != nil || len(reported) != 1 || reported[0] != "DP-4" {
215- t.Fatalf("hotplug failure must be reported and skipped: %v %v", err, reported)
216- }
217- if err := l.outputFailure(true, "lock surface", "DP-5", boom); err == nil || len(reported) != 1 {
218- t.Fatalf("initial failure must abort without a skip report: %v %v", err, reported)
219- }
220- for _, initial := range []bool{true, false} {
221- err := outputFailure(initial, "lock surface", "DP-3", fmt.Errorf("x: %w", wayland.ErrLockFinished))
222- if !errors.Is(err, ErrLockFinished) || !errors.Is(err, wayland.ErrLockFinished) {
223- t.Fatalf("finished (initial=%v) must map to ErrLockFinished: %v", initial, err)
224- }
225- }
226-}
227-
228-// Events kept during setup reach handle() exactly as reader events do.
229-func TestHeldLockEventsDriveLocker(t *testing.T) {
230- onLocked := 0
231- l := &locker{conn: &wayland.Connection{}, cfg: LockConfig{OnLocked: func() { onLocked++ }}}
232- if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil || !l.locked || onLocked != 1 {
233- t.Fatalf("locked lost: err=%v locked=%v calls=%d", err, l.locked, onLocked)
234- }
235- l.unlock = true
236- if !l.shouldUnlock() {
237- t.Fatal("unlock not possible after a held locked")
238- }
239- if err := l.handle(wayland.Event{Kind: wayland.LockFinished}); !errors.Is(err, ErrLockFinished) {
240- t.Fatalf("finished: %v", err)
241- }
242-}
243-
244-// A second locker is refused by the compositor with `finished`, which must
245-// surface as ErrLockFinished however early it arrives during setup.
246-func TestHarnessRunLockRefused(t *testing.T) {
247- if os.Getenv("NEFERGUI_HARNESS") != "1" {
248- t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness")
249- }
250- result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(t.TempDir(), "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockRefusedClient$", "-test.v"}})
251- out, _ := os.ReadFile(result.Artifacts["client_stdout"])
252- // A protected compositor withholds debug PNGs by design, so the harness may
253- // report a missing frame; this test only needs the client's refusal result.
254- noFrame := code == harness.ProcessFailure && strings.HasPrefix(result.Error, "no frame")
255- if (code != harness.Pass && !noFrame) || !strings.Contains(string(out), "LOCK refused ok") {
256- errOut, _ := os.ReadFile(result.Artifacts["client_stderr"])
257- t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut)
258- }
259-}
260-
261-func TestHarnessRunLockRefusedClient(t *testing.T) {
262- if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
263- t.Skip("harness child only")
264- }
265- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
266- defer cancel()
267- first, err := wayland.ConnectConnection(ctx, "")
268- if err != nil {
269- t.Fatal(err)
270- }
271- defer first.Close()
272- if err = first.AcquireLock(); err != nil {
273- t.Fatal(err)
274- }
275- // The first owner stays alive and unanswered; the compositor refuses the next.
276- err = RunLock(ctx, LockConfig{Secret: NewSecretBuffer(8), View: func(*Frame, LockState) {}})
277- if !errors.Is(err, ErrLockFinished) {
278- t.Fatalf("refused lock: %v", err)
279- }
280- fmt.Println("LOCK refused ok")
281-}
D internal/ui/outputs.go +0 -365
@@ -1,365 +0,0 @@
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/performance_test.go +2 -2
@@ -65,7 +65,7 @@ func demoRuntime(tb testing.TB) *runtime {
6565 tb.Fatal(err)
6666 }
6767 r := newRuntime()
68- stylesheet, err := os.ReadFile("../../examples/demo/app.css")
68+ stylesheet, err := os.ReadFile("testdata/demo.css")
6969 if err != nil {
7070 tb.Fatal(err)
7171 }
@@ -92,7 +92,7 @@ func BenchmarkDemoFrame(b *testing.B) {
9292 }
9393 }
9494
95-func TestDemoFrameAllocationBudget(t *testing.T) {
95+func TestAllocDemoFrame(t *testing.T) {
9696 r := demoRuntime(t)
9797 if !r.Build(demoFrame) {
9898 t.Fatal("initial build failed")
D internal/ui/platform.go +0 -32
@@ -1,32 +0,0 @@
1-package ui
2-
3-import (
4- "github.com/bnema/nefergui/internal/css"
5- "github.com/bnema/nefergui/internal/platform/wayland"
6-)
7-
8-// cursorShape maps a CSS cursor keyword to a Wayland cursor shape.
9-func cursorShape(k css.Keyword) uint32 {
10- switch k {
11- case css.KeywordPointer:
12- return wayland.CursorPointer
13- case css.KeywordText:
14- return wayland.CursorText
15- case css.KeywordNotAllowed:
16- return wayland.CursorNotAllowed
17- }
18- return wayland.CursorDefault
19-}
20-
21-// Wayland surface coordinates are logical; only the renderer scales pixels.
22-func (r *runtime) routeInput(in wayland.Input) {
23- if in.Kind == wayland.InputReset {
24- r.route(platformInput{Kind: "leave"})
25- r.route(platformInput{Kind: "focus-out"}) // cancels all captured buttons
26- return
27- }
28- if in.Kind == "axis" && in.DX == 0 && in.DY == 0 {
29- return
30- }
31- r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: keyEvent{Name: in.Key.Name, Text: in.Key.Text, Pressed: in.Key.Pressed, Repeat: in.Key.Repeat, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl}, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
32-}
M internal/ui/runtime.go +0 -245
@@ -2,17 +2,8 @@ package ui
22
33 import (
44 "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "os"
9- "path/filepath"
105 "sync"
116
12- "github.com/bnema/nefergui/internal/platform/wayland"
13- "github.com/bnema/nefergui/internal/presentation/session"
14- "github.com/bnema/nefergui/internal/render"
15-
167 "github.com/bnema/nefergui/internal/css"
178 "github.com/bnema/nefergui/internal/edit"
189 "github.com/bnema/nefergui/internal/layout"
@@ -54,7 +45,6 @@ type runtime struct {
5445 redraw bool
5546 consumed bool // the last committed build handled input events
5647 inputRects []Rect
57- inputOut []wayland.Rect
5848 inputNil, inputStaged bool
5949 }
6050
@@ -263,238 +253,3 @@ func (r *runtime) Wait(ctx context.Context) error {
263253 return nil
264254 }
265255 }
266-
267-// Run builds and presents an immediate view on the Wayland session owner loop.
268-func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {
269- return runWithCommit(ctx, model, view, nil, options...)
270-}
271-
272-// RunFrames is a deterministic harness entry: each commit requests a redraw.
273-func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error {
274- if frames < 1 {
275- return errors.New("nefergui: frames must be positive")
276- }
277- return runWithCommit(ctx, model, view, func(frame uint64) (bool, error) {
278- if committed != nil {
279- if err := committed(frame); err != nil {
280- return false, err
281- }
282- }
283- return frame >= uint64(frames), nil
284- }, options...)
285-}
286-
287-func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), committed func(uint64) (bool, error), options ...WindowOption) (err error) {
288- if model == nil || view == nil {
289- return errors.New("nefergui: nil model or view")
290- }
291- if err := ctx.Err(); err != nil {
292- return err
293- }
294- cfg := windowConfig{title: "NeferGUI", width: 800, height: 600}
295- for _, o := range options {
296- if o == nil {
297- return errors.New("nefergui: nil window option")
298- }
299- o.window(&cfg)
300- }
301- if cfg.width <= 0 || cfg.height <= 0 || int64(cfg.width) > 1<<31-1 || int64(cfg.height) > 1<<31-1 {
302- return fmt.Errorf("nefergui: invalid size %dx%d", cfg.width, cfg.height)
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- }
310- r := newRuntime()
311- if cfg.styles != "" {
312- data, err := os.ReadFile(cfg.styles)
313- if err != nil {
314- return fmt.Errorf("nefergui: styles: %w", err)
315- }
316- r.styles = css.Compile(css.UA(), css.Parse(string(data)))
317- }
318- catalog, err := text.Load(text.SystemSource{})
319- if err != nil {
320- return fmt.Errorf("nefergui: fonts: %w", err)
321- }
322- if len(catalog.Faces) == 0 {
323- return errors.New("nefergui: no usable fonts")
324- }
325- r.setTextEngine(text.NewEngine(catalog))
326- var surface wayland.SurfaceOptions
327- if cfg.layer != nil {
328- if surface, err = cfg.layer.surfaceOptions(); err != nil {
329- return err
330- }
331- }
332- s, err := session.OpenWithOptions(ctx, "", int32(cfg.width), int32(cfg.height), cfg.transparent, surface)
333- if err != nil {
334- return fmt.Errorf("nefergui: open platform: %w", err)
335- }
336- // Close errors (debug artifact writes) are reported after any earlier error.
337- defer func() { err = errors.Join(err, s.Close()) }()
338- if cfg.onSurface != nil {
339- if err := startupHook(ctx, cfg.onSurface, WaylandSurface{Display: s.Window.Display, Surface: s.Window.Surface}); err != nil {
340- if ctx.Err() != nil {
341- return err
342- }
343- return fmt.Errorf("nefergui: surface hook: %w", err)
344- }
345- }
346- if err := s.Window.SetTitle(cfg.title); err != nil { // no-op for layer surfaces
347- return fmt.Errorf("nefergui: title: %w", err)
348- }
349- r.pasteCtx = ctx
350- defer func() {
351- if r.pasteCancel != nil {
352- r.pasteCancel()
353- }
354- }()
355- var clipboard *wayland.Clipboard
356- var ime *wayland.IME
357- if s.Window.Seat != nil {
358- clipboard, err = wayland.NewClipboard(s.Window.Display, s.Window.Seat, func() uint32 {
359- if a := s.Window.InputAdapter(); a != nil {
360- return a.SelectionSerial()
361- }
362- return 0
363- }, s.Window.PostClipboard)
364- if err != nil {
365- return fmt.Errorf("nefergui: clipboard: %w", err)
366- }
367- defer clipboard.Close()
368- ime, err = wayland.NewIME(s.Window.Display, s.Window.Seat, s.Window.Surface, s.Window.PostIME)
369- if err != nil {
370- return fmt.Errorf("nefergui: IME: %w", err)
371- }
372- defer ime.Close()
373- }
374- if clipboard != nil {
375- r.clipboard = clipboard
376- }
377- if ime != nil {
378- r.ime = ime
379- }
380- loopCtx, stop := context.WithCancel(ctx)
381- defer stop()
382- if cfg.wake != nil {
383- go r.forwardWake(loopCtx, cfg.wake)
384- }
385- hooks := &inputHooks{onInput: cfg.onInput, onResize: cfg.onResize}
386- finished := false
387- err = s.RunApp(loopCtx, session.AppHooks{
388- Wake: r.wake,
389- Input: func(inputs []wayland.Input) error {
390- for _, input := range inputs {
391- // An overflow reset keeps the latest enter or leave in the
392- // queue, so the serial stays valid across it.
393- switch {
394- case input.Enter:
395- s.Window.PointerEntered(input.Serial, input.PointerGen)
396- case input.Kind == "leave":
397- s.Window.PointerLeft()
398- }
399- if hooks.input(input) { // raw hook first; controls still route the event
400- r.Redraw()
401- }
402- r.routeInput(input)
403- }
404- // Hover moves without a rebuild; update the cursor from the last
405- // committed styles now, and again after the next build.
406- return s.Window.SetCursor(cursorShape(r.cursor()))
407- },
408- Event: func(ev wayland.Event) error {
409- switch ev.Kind {
410- case wayland.ClipboardInput:
411- if clipboard != nil {
412- clipboard.ApplyClipboard(ev.Clipboard)
413- }
414- case wayland.IMEInput:
415- if ime != nil {
416- if batch, ok := ime.ApplyIME(ev.IME); ok {
417- r.route(platformInput{Kind: "ime-done", IME: batch})
418- }
419- }
420- }
421- return nil
422- },
423- Committed: func(frame uint64) error {
424- if committed == nil {
425- return nil
426- }
427- done, err := committed(frame)
428- if err != nil {
429- return err
430- }
431- if done {
432- finished = true
433- stop()
434- } else {
435- r.Redraw()
436- }
437- return nil
438- },
439- Draw: func() (render.Frame, bool, error) {
440- if hooks.resize(int(s.Window.Width), int(s.Window.Height), s.Window.Scale) {
441- r.Redraw() // an OnResize callback may have changed the model
442- }
443- r.route(platformInput{Kind: "resize", Width: float64(s.Window.Width), Height: float64(s.Window.Height), Scale: s.Window.Scale})
444- if !r.Build(func(f *Frame) { view(f, model) }) {
445- return render.Frame{}, false, nil
446- }
447- if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" {
448- data, e := json.Marshal(r.output.Tree)
449- if e != nil {
450- return render.Frame{}, false, e
451- }
452- if e = os.WriteFile(filepath.Join(dir, "layout.json"), data, 0600); e != nil {
453- return render.Frame{}, false, e
454- }
455- }
456- if ime != nil && ime.Err != nil {
457- return render.Frame{}, false, ime.Err
458- }
459- if err := r.applyInputRects(s.Window.StageInputRects); err != nil {
460- return render.Frame{}, false, err
461- }
462- if err := s.Window.SetCursor(cursorShape(r.cursor())); err != nil {
463- return render.Frame{}, false, err
464- }
465- w, h, err := s.Window.PhysicalSize()
466- if err != nil {
467- return render.Frame{}, false, err
468- }
469- frame, err := s.Preparer.Prepare(r.output.Display, s.Window.Scale, int(w), int(h))
470- return frame, true, err
471- },
472- })
473- if finished && errors.Is(err, context.Canceled) && ctx.Err() == nil {
474- return nil
475- }
476- return err
477-}
478-
479-type WindowOption interface{ window(*windowConfig) }
480-type windowConfig struct {
481- title, styles string
482- width, height int
483- transparent bool
484- layer *LayerConfig
485- onInput func(InputEvent) bool
486- onResize func(int, int, float64)
487- wake <-chan struct{}
488- onSurface func(context.Context, WaylandSurface) error
489-}
490-type windowOption func(*windowConfig)
491-
492-func (o windowOption) window(c *windowConfig) { o(c) }
493-func Title(s string) WindowOption { return windowOption(func(c *windowConfig) { c.title = s }) }
494-func Size(w, h int) WindowOption {
495- return windowOption(func(c *windowConfig) { c.width = w; c.height = h })
496-}
497-func Styles(s string) WindowOption { return windowOption(func(c *windowConfig) { c.styles = s }) }
498-
499-// Transparent requests an alpha-capable Wayland surface (opaque by default).
500-func Transparent() WindowOption { return windowOption(func(c *windowConfig) { c.transparent = true }) }
R examples/demo/app.css → internal/ui/testdata/demo.css +0 -0 renamed, 100% similar

renamed, 100% similar

M nefergui.go +19 -109
@@ -1,25 +1,23 @@
1-// Package nefergui provides a pure-Go immediate-mode GUI for Wayland, rendered with
2-// Vulkan and styled with a bounded CSS dialect (see docs/css.md).
1+// Package nefergui is a pure-Go immediate-mode GUI renderer styled with a
2+// bounded CSS dialect (see docs/css.md). It draws with Vulkan into DMA-BUF
3+// buffers and imports no Wayland library: the application owns the Wayland
4+// client (for example with github.com/bnema/neferclient) and feeds events to a
5+// Renderer.
36 //
4-// Run opens a window and calls the view function to build each frame. The view
5-// receives a Frame; Frame and its Node handles are valid only during that call.
6-// Key identifies a child among its siblings across frames; without a key,
7-// position and type determine identity. ID is CSS metadata, not frame identity.
8-// Options such as Title, Size, Styles and Transparent configure the window.
7+// A view function builds each frame from a Frame; Frame and its Node handles
8+// are valid only during that call. Key identifies a child among its siblings
9+// across frames; without a key, position and type determine identity. ID is
10+// CSS metadata, not frame identity.
911 //
1012 // The frame tree, controls, editors and input routing are implemented in
1113 // internal/ui; this package is the public facade and re-exports those types.
1214 //
13-// Builds support CGO_ENABLED=0. At runtime, NeferGUI requires libvulkan.so.1,
14-// libxkbcommon.so.0 and a Wayland compositor supporting linux-dmabuf and
15-// linux-drm-syncobj. See docs/runtime.md and docs/troubleshooting.md.
15+// Builds support CGO_ENABLED=0. At runtime NeferGUI requires libvulkan.so.1 and
16+// a GPU with DMA-BUF export and DRM syncobj timelines. See docs/runtime.md and
17+// docs/troubleshooting.md.
1618 package nefergui
1719
18-import (
19- "context"
20-
21- "github.com/bnema/nefergui/internal/ui"
22-)
20+import "github.com/bnema/nefergui/internal/ui"
2321
2422 // Frame is valid only during one call to the view function. Root builds the
2523 // application element; Diagnostics reports identity problems in debug builds.
@@ -56,7 +54,6 @@ type (
5654 EditOption = ui.EditOption
5755 ValueOption = ui.ValueOption
5856 HeadingOption = ui.HeadingOption
59- WindowOption = ui.WindowOption
6057 )
6158
6259 // commonOption belongs to every option family; disabledOption to every
@@ -91,47 +88,17 @@ func Placeholder(v string) EditOption { return ui.Placeholder(v) }
9188 // Password masks displayed editor text and blocks copy and cut.
9289 func Password(v bool) EditOption { return ui.Password(v) }
9390
94-// Title sets the window title.
95-func Title(s string) WindowOption { return ui.Title(s) }
96-
97-// Size sets the initial logical window size.
98-func Size(w, h int) WindowOption { return ui.Size(w, h) }
99-
100-// Styles loads an author stylesheet from a file path.
101-func Styles(s string) WindowOption { return ui.Styles(s) }
91+// Rect is an integer logical-pixel rectangle in surface coordinates, used by
92+// Frame.SetInputRects and Output.InputRects.
93+type Rect = ui.Rect
10294
103-// Transparent requests an alpha-capable Wayland surface (opaque by default).
104-func Transparent() WindowOption { return ui.Transparent() }
105-
106-// Layer surface, input and wake configuration. Layer selects a wlr-layer-shell
107-// surface instead of an xdg toplevel; OnInput, OnResize and Wake integrate
108-// callers that own state outside the view. See internal/ui for details.
95+// InputKind classifies an Input; Modifiers is a bit set of held modifiers.
10996 type (
110- LayerConfig = ui.LayerConfig
111- LayerLevel = ui.LayerLevel
112- Anchor = ui.Anchor
113- KeyboardMode = ui.KeyboardMode
114- Rect = ui.Rect
115- InputEvent = ui.InputEvent
116- InputKind = ui.InputKind
117- Modifiers = ui.Modifiers
97+ InputKind = ui.InputKind
98+ Modifiers = ui.Modifiers
11899 )
119100
120101 const (
121- LayerBackground = ui.LayerBackground
122- LayerBottom = ui.LayerBottom
123- LayerTop = ui.LayerTop
124- LayerOverlay = ui.LayerOverlay
125-
126- AnchorTop = ui.AnchorTop
127- AnchorBottom = ui.AnchorBottom
128- AnchorLeft = ui.AnchorLeft
129- AnchorRight = ui.AnchorRight
130-
131- KeyboardNone = ui.KeyboardNone
132- KeyboardExclusive = ui.KeyboardExclusive
133- KeyboardOnDemand = ui.KeyboardOnDemand
134-
135102 InputPointerMotion = ui.InputPointerMotion
136103 InputPointerPress = ui.InputPointerPress
137104 InputPointerRelease = ui.InputPointerRelease
@@ -145,60 +112,3 @@ const (
145112 ModShift = ui.ModShift
146113 ModCtrl = ui.ModCtrl
147114 )
148-
149-// Layer makes the window a layer-shell surface.
150-func Layer(c LayerConfig) WindowOption { return ui.Layer(c) }
151-
152-// OnInput receives raw input on the owner loop before control routing; return
153-// true to request a redraw.
154-func OnInput(fn func(InputEvent) bool) WindowOption { return ui.OnInput(fn) }
155-
156-// OnResize reports logical size and scale at start and on change only.
157-func OnResize(fn func(width, height int, scale float64)) WindowOption { return ui.OnResize(fn) }
158-
159-// WaylandSurface is the window's Wayland display and wl_surface for OnSurface.
160-type WaylandSurface = ui.WaylandSurface
161-
162-// OnSurface runs fn once after the surface role is configured and before the
163-// first buffer; an error aborts Run.
164-func OnSurface(fn func(context.Context, WaylandSurface) error) WindowOption { return ui.OnSurface(fn) }
165-
166-// Wake requests a redraw for each value received on ch.
167-func Wake(ch <-chan struct{}) WindowOption { return ui.Wake(ch) }
168-
169-// Run builds and presents an immediate view on the Wayland session owner loop.
170-func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {
171- return ui.Run(ctx, model, view, options...)
172-}
173-
174-// RunFrames is a deterministic harness entry: each commit requests a redraw
175-// and the loop stops after the given number of committed frames.
176-func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error {
177- return ui.RunFrames(ctx, frames, model, view, committed, options...)
178-}
179-
180-// Session lock. RunLock acquires ext-session-lock, covers every output with
181-// opaque black through the normal Vulkan/DMA-BUF/explicit-sync path and hosts
182-// the caller's view on one output. See docs/session-lock.md.
183-type (
184- LockConfig = ui.LockConfig
185- LockState = ui.LockState
186- LockStatus = ui.LockStatus
187- SecretBuffer = ui.SecretBuffer
188-)
189-
190-const (
191- LockIdle = ui.LockIdle
192- LockBusy = ui.LockBusy
193- LockFailed = ui.LockFailed
194-)
195-
196-// ErrLockFinished reports that the compositor refused or ended the lock.
197-var ErrLockFinished = ui.ErrLockFinished
198-
199-// NewSecretBuffer allocates a caller-owned secret input buffer (1..4096 bytes).
200-func NewSecretBuffer(capacity int) *SecretBuffer { return ui.NewSecretBuffer(capacity) }
201-
202-// RunLock runs a session lock until an unlock requested on LockConfig.Unlock,
203-// a failure, or cancellation. It never unlocks on failure or cancellation.
204-func RunLock(ctx context.Context, cfg LockConfig) error { return ui.RunLock(ctx, cfg) }

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