detached 5fd5066

Merge pull request #6 from bnema/refactor/wayland-free

refactor!: make NeferGUI a Wayland-free graphics library
commit
5fd5066ab41efc20d3ddfff2a5964eb5011e7273
author
bnema · · 2026-10-01 19:22 UTC
committer
GitHub · · 2026-10-01 19:22 UTC
merge of
2de4e1d + 360f051 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.5.0
130 files · +3872 -17980

this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.

A .github/workflows/ci.yml +33 -0
@@ -0,0 +1,33 @@
1+name: CI
2+on:
3+ push:
4+ branches: [main]
5+ pull_request:
6+concurrency:
7+ group: ci-${{ github.ref }}
8+ cancel-in-progress: true
9+jobs:
10+ # Arch matches the development system.
11+ check:
12+ runs-on: ubuntu-latest
13+ container:
14+ image: archlinux:latest
15+ # An init process reaps killed children, as on a real session.
16+ options: --init
17+ steps:
18+ # The tests run without a compositor or a GPU. gcc is for the race
19+ # detector.
20+ - run: >-
21+ pacman -Syu --noconfirm --needed
22+ git go make gcc
23+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24+ with:
25+ persist-credentials: false
26+ - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
27+ - run: |
28+ go install github.com/vektra/mockery/v3@v3.8.0
29+ go install honnef.co/go/tools/cmd/staticcheck@latest
30+ echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
31+ - run: make check
32+ - run: make race
33+ - run: make mocks-check
A .mockery.yml +38 -0
@@ -0,0 +1,38 @@
1+all: false
2+formatter: goimports
3+force-file-write: true
4+structname: '{{.Mock}}{{.InterfaceName}}'
5+template: testify
6+template-schema: '{{.Template}}.schema.json'
7+require-template-schema-exists: true
8+packages:
9+ # Unexported GPU seam of Renderer: the mock lives next to the code, in a test file.
10+ github.com/bnema/nefergui/internal/ui:
11+ config:
12+ dir: internal/ui
13+ pkgname: ui
14+ filename: '{{.InterfaceName}}_mock_test.go'
15+ interfaces:
16+ target:
17+ # Editor ports: same-package mocks in test files.
18+ github.com/bnema/nefergui/internal/edit:
19+ config:
20+ dir: internal/edit
21+ pkgname: edit
22+ filename: '{{.InterfaceName}}_mock_test.go'
23+ interfaces:
24+ IME:
25+ configs:
26+ - {}
27+ # Also mocked next to the UI tests that drive the editors.
28+ - dir: internal/ui
29+ pkgname: ui
30+ Clipboard:
31+ configs:
32+ - {}
33+ - dir: internal/ui
34+ pkgname: ui
35+ AsyncClipboard:
36+ configs:
37+ - dir: internal/ui
38+ pkgname: ui
A Makefile +23 -0
@@ -0,0 +1,23 @@
1+.PHONY: build test vet race mocks mocks-check fakes-check perf-check check
2+
3+build:
4+ CGO_ENABLED=0 go build ./...
5+test:
6+ CGO_ENABLED=0 go test ./...
7+vet:
8+ CGO_ENABLED=0 go vet ./...
9+race:
10+ CGO_ENABLED=1 go test -race ./...
11+mocks:
12+ mockery
13+mocks-check: mocks
14+ git diff --exit-code -- ':(glob)internal/**/*_mock_test.go'
15+ @test -z "$$(git ls-files --others --exclude-standard -- ':(glob)internal/**/*_mock_test.go')"
16+fakes-check:
17+ @rc=0; grep -rniE '^\s*type\s+\w*(fake|stub|spy|dummy|mock)\w*|^\s*\w*(fake|stub|spy|dummy|mock)\w*(\[[^]]*\])?\s+(struct|interface)\b' --include='*_test.go' --exclude='*_mock_test.go' . 2>/dev/null || rc=$$?; \
18+ [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && printf '%s\n' 'handwritten test double: generate with Mockery v3' >&2; exit 1; }
19+# Allocation guards (testing.AllocsPerRun).
20+perf-check:
21+ CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./...
22+check: build vet test fakes-check perf-check
23+ CGO_ENABLED=0 staticcheck ./...
M README.md +31 -34
@@ -1,54 +1,52 @@
11 # NeferGUI
22
3-A native Go GUI library for Linux: Wayland windows, Vulkan rendering, and CSS styling. Build your view from the current model; controls return events during that call. No cgo required.
3+A pure-Go GUI renderer for Linux: build your view from the current model, style it with CSS, and draw it with Vulkan into DMA-BUF buffers. Controls return events during the view call. No cgo required.
4+
5+NeferGUI imports no Wayland library. Your application owns the Wayland client (connection, surface, linux-dmabuf feedback, buffer import, seat events) and drives a `Renderer`; [github.com/bnema/neferclient](https://github.com/bnema/neferclient) is a client toolkit built for this. The two do not import each other: the application copies plain fields between them.
46
57 > [!WARNING]
68 > **Early development.** No release yet. Expect bugs and breaking changes. Platform accessibility is not connected.
79
810 ## A small example
911
12+The view is plain Go. The model type is yours.
13+
1014 ```go
11-package main
12-
13-import (
14- "context"
15- "fmt"
16- "log"
17-
18- "github.com/bnema/nefergui"
19-)
20-
21-func main() {
22- count := 0
23- view := func(f *nefergui.Frame, count *int) {
24- root := f.Root().Column()
25- root.Text("Hello, Wayland!")
26- if root.Button("Count", nefergui.Key("count")).Activated() {
27- *count++
28- }
29- root.Text(fmt.Sprintf("Clicked %d times", *count))
30- }
31- if err := nefergui.Run(context.Background(), &count, view,
32- nefergui.Title("Hello"), nefergui.Size(320, 200)); err != nil {
33- log.Fatal(err)
15+type Model struct{ Count int }
16+
17+view := func(f *nefergui.Frame, m *Model) {
18+ root := f.Root().Column()
19+ root.Text("Hello, Wayland!")
20+ if root.Button("Count", nefergui.Key("count")).Activated() {
21+ m.Count++
3422 }
23+ root.Text(fmt.Sprintf("Clicked %d times", m.Count))
3524 }
3625 ```
3726
38-Default styles work without a stylesheet; `nefergui.Styles("app.css")` loads your own CSS.
27+The client loop, on one owner goroutine:
3928
40-## Try it
29+```go
30+r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: dev, Formats: formats})
31+r.Resize(320, 200, 1) // on configure and scale changes
32+r.Input(&nefergui.Input{...}) // for each pointer, key and focus event
33+
34+var out nefergui.Output
35+if ok, err := r.Render(&out, &model, view); ok && err == nil {
36+ // import out.Planes as a wl_buffer when out.NewBuffer, attach with the
37+ // acquire/release points, commit
38+}
39+r.Released(buffer) // when out.ReleaseFD becomes readable
40+```
4141
42-Requires Go 1.27, `libvulkan.so.1`, `libxkbcommon.so.0`, usable fonts, and a Wayland compositor supporting linux-dmabuf and linux-drm-syncobj. Fractional scaling also requires viewporter. No X11 or software-rendering fallback.
42+`RendererConfig.Styles` loads your own CSS; default styles work without it. See `ExampleRenderer` and [runtime](docs/runtime.md) for the full contract.
4343
44-```sh
45-go get github.com/bnema/nefergui
46-```
44+## Requirements
4745
48-From a checkout, run the [demo](examples/demo):
46+Go 1.27, `libvulkan.so.1` and usable fonts. The GPU must export DMA-BUF and support DRM syncobj timelines (kernel 6.6 or later for eventfd waits). The compositor, reached through your Wayland client, must offer linux-dmabuf and linux-drm-syncobj; fractional scaling also needs viewporter. No X11 or software-rendering fallback.
4947
5048 ```sh
51-CGO_ENABLED=0 go run ./examples/demo
49+go get github.com/bnema/nefergui
5250 ```
5351
5452 ## Documentation
@@ -56,8 +54,7 @@ CGO_ENABLED=0 go run ./examples/demo
5654 - [Controls](docs/controls.md) and [layout](docs/layout.md)
5755 - [CSS properties and limitations](docs/css.md)
5856 - [Runtime requirements and behavior](docs/runtime.md) and [troubleshooting](docs/troubleshooting.md)
59-- [Visual test harness](docs/harness.md) and [performance](docs/performance.md)
60-- [Session lock client](docs/session-lock.md)
57+- [Performance](docs/performance.md)
6158 - [Architecture decisions](docs/adr)
6259
6360 ## License
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.
M docs/adr/0003-vulkan-dmabuf-presentation.md +15 -11
@@ -4,25 +4,29 @@ Status: accepted
44
55 ## Decision
66
7-NeferGUI renders with Vulkan into exportable images and presents them as `zwp_linux_dmabuf_v1` buffers. It does not use `VK_KHR_swapchain`.
7+NeferGUI renders with Vulkan into exportable images and hands them to the application as DMA-BUF buffers (`Renderer`, see ADR 0007). The application presents them through `zwp_linux_dmabuf_v1`. NeferGUI does not use `VK_KHR_swapchain`.
88
9-- Synchronization uses `wp_linux_drm_syncobj_v1` with Vulkan timeline semaphores. There is no CPU-fence fallback.
10-- The Vulkan physical device must match the `main_device` of linux-dmabuf feedback. There is no cross-GPU fallback.
11-- Formats: one-plane `XRGB8888` for opaque windows (default) and `ARGB8888` for transparent windows, both mapped to `VK_FORMAT_B8G8R8A8_UNORM`, with a modifier supported by both the compositor and the GPU.
12-- Three exportable images per window.
9+- Synchronization uses DRM syncobj timelines (`wp_linux_drm_syncobj_v1` on the Wayland side) with Vulkan timeline semaphores. There is no CPU-fence fallback.
10+- The Vulkan physical device must match the `main_device` of linux-dmabuf feedback, passed in `RendererConfig.MainDevice`. There is no cross-GPU fallback.
11+- Formats: one-plane `XRGB8888` by default and `ARGB8888` when `RendererConfig.Transparent` is set, both mapped to `VK_FORMAT_B8G8R8A8_UNORM`, with a modifier supported by both the compositor (`RendererConfig.Formats`) and the GPU.
12+- Three exportable images per Renderer, re-created on resize.
1313 - Shaders are GLSL compiled by `glslc` through `go generate`. SPIR-V is committed and embedded; consumers do not need `glslc`.
1414
1515 ## Buffer lifecycle
1616
1717 ```text
18-Available → Rendering → acquire point exported → committed with acquire/release points
19-→ compositor reads → release point of its latest commit reached → Available
18+Available → Rendering → acquire point exported → Output returned by Render
19+→ compositor reads → release point of its latest commit signaled → Released → Available
2020 ```
2121
22-While a `wp_linux_drm_syncobj_surface_v1` exists, `wl_buffer.release` delivery is undefined (linux-drm-syncobj-v1). A buffer is reused only after the release point set for its most recent commit is signaled; each buffer has its own release timeline because points may signal out of order. `wl_buffer.release` is traced when it arrives but never gates reuse. Frame callbacks schedule drawing; they never authorize reuse.
22+Each buffer has its own release timeline, because points may signal out of order. After each `Render` the Renderer arms an eventfd on the release point of that commit (`DRM_IOCTL_SYNCOBJ_EVENTFD`, one-shot). `Output.ReleaseFD` is that eventfd. When it becomes readable the application calls `Released(buffer)`, which reads the eventfd and returns the buffer to the pool. A buffer is reused only after the release point set for its most recent commit has signaled; `wl_buffer.release` never gates reuse, because its delivery is undefined while a syncobj surface exists. Frame callbacks schedule drawing; they never authorize reuse.
2323
24-The UI loop never blocks in Wayland dispatch while GPU or compositor completion is pending. Blocking sources (Wayland socket, release timeline waits) run on their own goroutines and only post events to the loop, which owns all presentation state. Resize creates a new generation; old images are retired after their latest release point signals.
24+The Renderer starts no goroutine and never blocks on the GPU or the compositor. `Render` returns false when no buffer is free; `Pending()` reports a built frame that waits for the GPU rather than the compositor, and the application retries after a short timer. Resize creates a new generation: buffers of the old one are reported in `Output.Retired` once their release point has signaled, and their descriptors stay open until the next `Render` or `Close`.
2525
26-At shutdown, release points protect reuse of contents, **not** the lifetime of exported DMA-BUF memory: kernel FD references and compositor imports are independent of the client's Vulkan allocation. Detach and commit null, destroy the syncobj surface, wait at most 200 ms for outstanding release points for tracing, wait for client GPU completion, destroy `wl_buffer`s and **all** client images/memory/semaphores/syncobj handles, then destroy the device. Never abandon Vulkan allocations solely because a compositor release point has not arrived.
26+All descriptors in `Output` (DMA-BUF planes, timelines, eventfds) stay owned by the Renderer. The application duplicates them before a transport consumes them.
2727
28-`Opaque()` sets a full-surface opaque region. `Transparent()` uses premultiplied alpha and no opaque region. Background blur is a compositor setting, not a NeferGUI feature.
28+## Shutdown
29+
30+Release points protect reuse of contents, **not** the lifetime of exported DMA-BUF memory: kernel descriptor references and compositor imports are independent of the client's Vulkan allocation. `Close` waits for client GPU completion, then frees **all** images, memory, semaphores, syncobj handles and eventfds, and destroys the device. It never abandons Vulkan allocations because a compositor release point has not arrived. The application detaches and destroys its own Wayland buffers and syncobj surface before or after `Close`; the Renderer does not know about them. `Close` also returns any Vulkan validation errors recorded while `NEFERGUI_VULKAN_VALIDATION=1` was set.
31+
32+Opaque or transparent regions, background blur and cursor mechanics belong to the application: `Output.InputRects` and `Output.Cursor` carry the requests, and `RendererConfig.Transparent` selects premultiplied alpha output.
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`.
A docs/adr/0007-wayland-free-renderer.md +23 -0
@@ -0,0 +1,23 @@
1+# ADR 0007 — Wayland-free Renderer
2+
3+Status: accepted
4+
5+## Decision
6+
7+NeferGUI is a rendering library. It builds views, lays them out, styles them with CSS and draws them with Vulkan into DMA-BUF buffers. The library does not own a Wayland connection.
8+
9+The public entry point is `Renderer` (`NewRenderer`, `Resize`, `Input`, `Invalidate`, `Render`, `Released`, `Wake`, `Pending`, `Close`). The application owns the Wayland client and runs one owner goroutine:
10+
11+- it gives the compositor's linux-dmabuf `main_device` and format list to `NewRenderer`;
12+- it forwards size, scale and input as plain `Input` values (keysyms are xkb values; text is UTF-8);
13+- `Render` fills an `Output`: DMA-BUF planes, syncobj timelines, acquire and release points, damage, cursor shape and input region. All file descriptors stay owned by the Renderer; the application duplicates them before a transport consumes them;
14+- an eventfd per buffer becomes readable when the compositor releases it; the application then calls `Released`.
15+
16+Layering is application → Wayland client library → bindings. NeferGUI sits beside the client library, not above it. Applications copy plain fields between the two; neither imports the other. [github.com/bnema/neferclient](https://github.com/bnema/neferclient) is a client toolkit written for this split.
17+
18+## Consequences
19+
20+- The module depends only on purego, purego-vulkan, go-text, x/image and x/sys.
21+- Clipboard and input-method ports (`Clipboard`, `IME`) are interfaces the application implements.
22+- `Password(true)` masks the displayed value of an in-memory editor value.
23+- ADR 0003 describes the Vulkan, DMA-BUF and explicit-sync design.
M docs/controls.md +3 -3
@@ -10,13 +10,13 @@ Import `github.com/bnema/nefergui`. Public `Frame`, `Node` and event types alias
1010
1111 Checkbox and radio paint a one-em indicator, centered vertically, in their left padding (UA `padding-left: 1.5em`). Unchecked, it is an outline in `color` at 45% alpha. Checked, it is filled with `accent-color` and carries a white mark: a rounded square for checkboxes, a dot for radios. The slider paints, in its bottom padding (UA `padding-bottom: 1.25em`), a rail across the content width the pointer maps to, filled with `accent-color` up to the value, plus a round `accent-color` thumb. The indicator size follows `font-size`; `accent-color` (UA default `#3584e4`), `color`, `opacity` and `:checked` let author CSS restyle them, for example `checkbox:checked { accent-color: green }`. The painter fixes the rest: shapes and proportions, the white mark and the 45% outline alpha. Indicators stay visible when the control clips its content. The indicator always sits at the left, including for RTL labels. A slider with no bottom padding paints no track; checkbox and radio indicators still paint at the left edge, so keep their left padding. Disabled checkboxes, radios and sliders are dimmed by UA `opacity: 0.5`.
1212
13-The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.
13+The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. `Renderer` reports the shape in `Output.Cursor`; the application applies it, for example through `wp_cursor_shape_manager_v1`. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.
1414
1515 `Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries and soft wraps, caret painting, scrolling and preedit positioning use the preceding character's trailing edge. Up/Down navigation treats a shared soft-wrap offset as belonging to the following line. Password geometry maps offsets by grapheme, like the displayed mask. No undo/redo is implemented.
1616
17-`internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter.
17+`internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types or own FDs; the application implements them and passes them in `RendererConfig`.
1818
19-Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list.
19+Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. `Renderer` uses the same control tree and display list.
2020
2121 The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above.
2222
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`.
M docs/performance.md +6 -17
@@ -6,7 +6,7 @@ Measured: **2026-09-30, 07:52–07:54 CEST (UTC+02:00)** on commit `7ad3316fd1ad
66
77 AMD Ryzen 9 7900X3D; Go `go1.27.1-X:nodwarf5 linux/amd64`. Command: `CGO_ENABLED=0 GOWORK=off go test -mod=readonly -p=1 -run '^$' -bench . -benchmem -count=5 ./internal/ui ./internal/render ./internal/layout ./internal/text ./internal/css`. Package benchmarks run serially; external profiling runs are separate. Entries are medians of five runs. Budgets allow roughly twice the measured medians; zero-allocation paths stay at zero. DemoFrame has a deliberately tighter 1,100-allocation limit to catch editor-workspace regressions. CPU numbers vary with machine load.
88
9-The demo-frame benchmark builds the light, comfortable document workspace with the example's actual stylesheet, computes layout and produces a display list after warmup. It includes the toolbar, document list, populated multiline editor and properties pane with input, radios and slider; it excludes GPU submission. Its workload is larger than the older unstyled fixture, so those results are not directly comparable. Prepare uses a representative text/box command mix and excludes GPU submission. Paint uses 100 cards with two shadows and text; ListBatches uses 2,000 quads with interleaved images. ListBufferReuse and LayoutPaintTree warm their buffers and text cache before resetting the timer; their figures measure steady state.
9+The demo-frame benchmark builds the light, comfortable document workspace with a demo stylesheet (`internal/ui/testdata/demo.css`), computes layout and produces a display list after warmup. It includes the toolbar, document list, populated multiline editor and properties pane with input, radios and slider; it excludes GPU submission. Its workload is larger than the older unstyled fixture, so those results are not directly comparable. Prepare uses a representative text/box command mix and excludes GPU submission. Paint uses 100 cards with two shadows and text; ListBatches uses 2,000 quads with interleaved images. ListBufferReuse and LayoutPaintTree warm their buffers and text cache before resetting the timer; their figures measure steady state.
1010
1111 | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op |
1212 |---|---:|---:|---:|---:|---:|---:|
@@ -29,24 +29,17 @@ The demo-frame benchmark builds the light, comfortable document workspace with t
2929
3030 Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement.
3131
32-`TestDemoFrameAllocationBudget` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse, and allocation-free idle wait results. The real-ioctl allocation test is opt-in via `NEFERGUI_RENDER_NODE`. The other table budgets are reference targets, not automated assertions.
32+`TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measured 244 allocations per steady Render+Released cycle on 2026-10-01 (the guard fails above that baseline), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.
3333
3434 Frame construction limits allocation churn through:
3535
3636 - a detached quad array initially reserved up to 512 quads; larger visible frames grow after culling, so offscreen content cannot force an unbounded speculative allocation;
3737 - detached display-list storage sized once for commands, shadows, runs and glyphs;
38-- a session-owned instance/batch buffer, consumed synchronously before reuse, with image references cleared after use and oversized storage discarded when smaller frames arrive;
38+- a target-owned instance/batch buffer, consumed synchronously before reuse, with image references cleared after use and oversized storage discarded when smaller frames arrive;
3939 - font family normalization at font load and identity path keys computed once per element;
4040 - `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions.
4141
42-`Run` builds a frame only on input or a redraw request. Idle frame construction stops. Expected release-wait timeouts return without constructing errors; real ioctl failures retain their context and wrapped errno.
43-
44-On commit `9064a73`, measured **2026-09-30, 06:34 CEST**, `BenchmarkWaitPointIdle` on an unsignaled real DRM timeline took a median **21,370,548 ns/op, 0 B/op, 0 allocs/op**. This includes the requested 20 ms timeout, not active CPU time:
45-
46-```sh
47-NEFERGUI_RENDER_NODE=/dev/dri/renderD128 CGO_ENABLED=0 GOWORK=off \
48- go test -run '^$' -bench BenchmarkWaitPointIdle -benchtime=10x -benchmem -count=5 ./internal/presentation/syncobj
49-```
42+`Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops.
5043
5144 Profile frame construction with:
5245
@@ -77,14 +70,10 @@ An external Go module with minimal-counter and demo views, a 960×640 window at
7770
7871 Heap/RSS/PSS use the default Go profiling rate. Allocation figures use separate `GODEBUG=memprofilerate=1` runs: subtract cumulative profiles at frames 1 and 1,000, exclude stacks containing the measurement sampler or `runtime/pprof`, sum the remaining flat allocated bytes, then divide by 999 frames. These runs recorded about **41–43% less allocation churn** than the baseline. They do not demonstrate an equivalent reduction in RSS or frame latency. Single-run process-memory values vary with runtime and driver behavior.
7972
80-The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the session's reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own.
73+The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own.
8174
8275 DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active, unchanged from baseline and released at shutdown. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits.
8376
84-A separate approximately 20-second idle profile recorded about **91 KiB** allocated under `syncobj.WaitPoint` in the baseline and **no allocations attributed to that path** on the measured implementation. Other runtime and consumer instrumentation allocations remain; this is not a claim that the whole process allocates nothing at idle.
85-
86-The measured commit includes `purego-vulkan` v0.6.0, `wlturbo` v0.3.0 and `typesetting` v0.3.5. The subsequent `2e4532d` commit adds an empty-glyph regression test and raises the minimum compositor version checked at startup; it does not change the measured frame paths on this version-6 compositor. The comparison therefore includes both library optimizations and dependency updates, not isolated effects for each.
87-
88-Prepared quads and layout painting still allocate detached outputs. Editor and indicator command insertion can also reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles. `NEFERGUI_DEBUG_DIR` enables readbacks and PNG work; leave it unset when measuring ordinary application cost.
77+Prepared quads and layout painting still allocate detached outputs. Editor and indicator command insertion can also reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles.
8978
9079 Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory.
M docs/runtime.md +14 -19
@@ -1,32 +1,27 @@
11 # Immediate runtime
22
3-`Run(ctx, &model, view, options...)` opens a Wayland surface, renders with Vulkan and returns when the surface closes (`nil`) or `ctx` is cancelled. It uses an xdg toplevel by default; `Layer(LayerConfig)` chooses a layer-shell role with an output name, layer, anchors, margins, exclusive zone and keyboard mode. `Title`, `Size` (logical pixels), `Styles` (CSS file path) and `Transparent` configure the surface. Setup and teardown errors are returned; joined errors retain real failures alongside cancellation. Accessibility is not connected.
3+`NewRenderer(RendererConfig)` opens the GPU named by `MainDevice` (the `dev_t` of linux-dmabuf feedback), loads fonts and the optional `Styles` CSS file, and allocates no images. `Formats` is the compositor's flattened format and modifier list in preference order; `Transparent` selects ARGB8888 instead of XRGB8888. `Clipboard` and `IME` are optional ports the application implements. The Renderer imports no Wayland library, starts no goroutine and must be used from one owner goroutine; only `Wake` is safe from any goroutine. Accessibility is not connected.
44
5-`LayerConfig.AllOutputs` puts one layer surface on every output through one Wayland connection and owner loop. Outputs added later gain a surface; removed outputs lose theirs. Every surface runs the same view and model, laid out at its own size and scale; `Wake` and `OnInput` returning true redraw them all. `Run` then returns only on cancellation or failure. It cannot be combined with `Output`, keyboard interactivity, `OnSurface`, `OnResize` or `RunFrames`.
5+A client loop forwards events and presents frames:
66
7-`OnInput(func(InputEvent) bool)` observes input on the owner loop; return true only when application state changed and needs a redraw. `OnResize` runs at startup and on size or scale changes. `Wake` requests an event-driven redraw without a continuous frame loop. `OnSurface(func(context.Context, WaylandSurface) error)` configures extension protocols after the surface role exists and before the first buffer. Its display and surface belong to NeferGUI. Protocol callbacks run on the owner during startup and on the reader after the loop starts; callbacks must not mutate the application's model from the reader.
7+- `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.
8+- `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.
9+- `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.
10+- `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.
11+- `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.
12+- `Close` cancels pending pastes and frees GPU resources.
813
9-`Frame.SetInputRects` stages the clickable region with the frame's buffer commit. Nil restores the whole surface; a non-nil empty slice makes it click-through. `Node.Rect` places a border-box rectangle relative to a Stack's content box without parsing geometry CSS. It applies only to Stack children; debug builds diagnose other parents.
14+All file descriptors in `Output` stay owned by the Renderer; duplicate them before handing them to a transport that consumes descriptors. `Output.NewBuffer` says to import `Planes` as a new buffer, and `NewTimelines` says the timeline descriptors are new. Descriptors of retired buffers listed in `Output.Retired` stay open until the next `Render` or `Close`, so the client can stop watching and destroy them first. `Output` slices are reused and valid until the next `Render`. Allocation behavior of `Render` is described in [performance](performance.md).
1015
11-The UI loop never blocks on the GPU or the compositor. It applies Wayland events, drains seat input, fires key repeat from a timer, and builds `view` once per input batch or redraw request. A frame is prepared and presented only when the build or the window size/scale changed. Clipboard reads are asynchronous: paste starts a read and the text is inserted by the next frame. A new paste cancels the previous one. Input-method (text-input v3) batches are applied during frame construction.
16+`Frame.SetInputRects` stages the clickable region, reported in `Output.InputRects` only when it changes. Nil restores the whole surface; a non-nil empty slice makes it click-through. `Node.Rect` places a border-box rectangle relative to a Stack's content box without parsing geometry CSS. It applies only to Stack children; debug builds diagnose other parents. The cursor shape follows the CSS `cursor` of the hovered element and is reported in `Output.Cursor`; the client maps it to its own cursor mechanism.
1217
13-The unexported `runtime` constructs views synchronously. A concurrent or re-entrant build never waits: it requests a redraw, posts a wake token and returns false. The in-progress frame commits first; queued input remains for the next build.
14-
15-The unexported UI-loop port `platformInput` accepts logical pointer motion/press/release/axis/leave, interpreted `keyboard.Key` events, focus-in/out and logical viewport resize/scale. `route` hit-tests against the last committed layout (reverse paint order and ancestor clips), resolves the result to a frame identity and wakes the next build only when state changes. `Target` is a committed-child-path helper for tests. A build computes CSS pseudo-state and commits layout, display list and tree together. The headless viewport defaults to 800×600 logical pixels; `Run` sets it from the window. Scale is used by the renderer only and never affects hit testing: Wayland surface coordinates are already logical. A seat input-queue overflow is delivered as a reset (pointer leave, all buttons released, keyboard focus-out). `Run` installs a text engine from system fonts; headless tests install one explicitly, and without one text commands have no measured glyph runs.
16-
17-## Example
18+Clipboard reads are asynchronous: paste starts a read and the text is inserted by the next frame. A new paste cancels the previous one. Input-method batches are applied during frame construction.
1819
19-`examples/demo` is the product example with an embedded stylesheet:
20-
21-```sh
22-GOWORK=off go run ./examples/demo
23-```
24-
25-`--static --frames N` renders N frames and exits (used by the visual harness).
20+The unexported `runtime` constructs views synchronously. A concurrent or re-entrant build never waits: it requests a redraw, posts a wake token and returns false. The in-progress frame commits first; queued input remains for the next build.
2621
27-Tab traverses visible, enabled controls in document order and wraps; Shift+Tab reverses. Keyboard focus sets `:focus-visible`; pointer focus does not. Enter activates focused buttons and checkboxes on initial key press, ignoring repeats. Space sets `:active` on initial press and activates on release only if focus is unchanged; focus loss/change or Escape cancels it, and repeats are ignored. Disabled controls cannot focus or activate. When a focused control is removed or disabled, focus falls back to **none** (the next Tab starts at the first available control). The first pressed pointer button owns capture until its matching release, even outside the target; other button presses/releases cannot steal it or change focus. Only primary-button release on the captured control activates it. Pointer leave clears hover but preserves capture until matching release (an out-of-surface release cannot activate); removal or platform focus loss cancels capture. Scroll wheel deltas apply to the nearest scrollable ancestor and clamp to measured content bounds; offsets are discarded when that identity is no longer a laid-out scroll container. Unchanged state does not redraw. Control callbacks run during frame construction; input, resize and surface hooks run on the session owner at their documented lifecycle points.
22+The unexported input port `platformInput` accepts logical pointer motion/press/release/axis/leave, key events, focus-in/out and logical viewport resize/scale. `route` hit-tests against the last committed layout (reverse paint order and ancestor clips), resolves the result to a frame identity and wakes the next build only when state changes. `Target` is a committed-child-path helper for tests. A build computes CSS pseudo-state and commits layout, display list and tree together. Scale is used by the renderer only and never affects hit testing: surface coordinates are already logical. `NewRenderer` installs a text engine from system fonts; headless tests install one explicitly, and without one text commands have no measured glyph runs.
2823
29-Offline keyboard tests require libxkbcommon.so.0 and a usable XKB data installation; a host without those prerequisites may opt out with `NEFERGUI_SKIP_XKB=1` (other test failures still fail). The valid-FD test fixture is a French Wayland keymap generated with `xkbcli compile-keymap --layout fr --output-format 1` from libxkbcommon 1.13.2; SHA-256 `f6bcde4e4e03293d05c9426d08cdc2e9a78e7b8faef39dc97c5d267ad7355607`.
24+Tab traverses visible, enabled controls in document order and wraps; Shift+Tab reverses. Keyboard focus sets `:focus-visible`; pointer focus does not. Enter activates focused buttons and checkboxes on initial key press, ignoring repeats. Space sets `:active` on initial press and activates on release only if focus is unchanged; focus loss/change or Escape cancels it, and repeats are ignored. Disabled controls cannot focus or activate. When a focused control is removed or disabled, focus falls back to **none** (the next Tab starts at the first available control). The first pressed pointer button owns capture until its matching release, even outside the target; other button presses/releases cannot steal it or change focus. Only primary-button release on the captured control activates it. Pointer leave clears hover but preserves capture until matching release (an out-of-surface release cannot activate); removal or platform focus loss cancels capture. Scroll wheel deltas apply to the nearest scrollable ancestor and clamp to measured content bounds; offsets are discarded when that identity is no longer a laid-out scroll container. Unchanged state does not redraw. Control callbacks run during frame construction; the Renderer calls no application code outside `Render`.
3025
3126 Compile with `-tags nefergui_debug` to report duplicate sibling keys, changed unkeyed child types and stale Node use (stale use panics). Without this tag, stale handles are inert. Do not retain a Node across frames. `ID` is only for CSS, while `Key` identifies an element within its parent. Unkeyed children are identified by their position and type.
3227
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 docs/troubleshooting.md +2 -3
@@ -1,10 +1,9 @@
11 # Troubleshooting
22
3-- No display: `nefergui: open platform: ...` wraps the Wayland connection error. Check `WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR`, and that a Wayland compositor is running.
4-- Missing protocol: `Wayland capability <interface>: ...` identifies the required compositor global. NeferGUI needs linux-dmabuf and linux-drm-syncobj; switch to a compositor offering both.
3+- Wayland connection and missing globals are reported by the application's Wayland client, not by NeferGUI. The compositor must offer linux-dmabuf and linux-drm-syncobj.
54 - No suitable GPU: `no Vulkan physical devices; need DRM main_device ...`, `no Vulkan DRM device matches linux-dmabuf main_device ...`, or `matching Vulkan device missing ...` means the Vulkan driver cannot serve the compositor's DRM device. `no one-plane exportable B8G8R8A8 modifier shared with compositor ...` means they have no compatible modifier. Check driver and compositor support; there is no cross-GPU fallback.
65 - Fonts: `nefergui: fonts: no usable fonts: ...` or `nefergui: no usable fonts` means system font discovery failed. Install usable TrueType/OpenType system fonts.
76 - `NEFERGUI_VULKAN_VALIDATION=1` enables Vulkan validation; an unavailable validation layer reports `validation layer VK_LAYER_KHRONOS_validation unavailable`. Install the validation layer or unset the variable. Broken implicit Vulkan layers can prevent startup: use `VK_LOADER_LAYERS_DISABLE=<layer>` to disable the offending layer for diagnosis.
8-- `NEFERGUI_DEBUG_DIR` writes diagnostic artifacts, including `frame-timings.jsonl` and `buffer-lifecycle.jsonl`; choose a writable directory. Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles).
7+- Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles).
98 - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`.
109 - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig.
M example_test.go +36 -54
@@ -1,74 +1,56 @@
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
54- }
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+ // One iteration of the client loop; real clients repeat it forever.
44+ drawn, err := r.Render(&out, m, view)
45+ if err != nil {
46+ log.Fatal(err)
5547 }
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)
70- }
71- root.Footer(nefergui.Class("status-bar")).Text(m.Status)
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
7253 }
73- _ = nefergui.Run(context.Background(), &model, app, nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles("app.css"))
54+ // Then sleep until a Wayland event, <-r.Wake(), or a short timer when
55+ // r.Pending() reports a frame waiting for the GPU.
7456 }
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-}
M go.mod +6 -3
@@ -5,11 +5,14 @@ go 1.27
55 require (
66 github.com/bnema/purego v0.13.0-bnema.1
77 github.com/bnema/purego-vulkan v0.6.0
8- github.com/bnema/purego-xkbcommon v0.1.0
9- github.com/bnema/wlturbo v0.5.0
108 github.com/go-text/typesetting v0.3.5
9+ github.com/stretchr/testify v1.12.1
1110 golang.org/x/image v0.46.0
1211 golang.org/x/sys v0.48.0
1312 )
1413
15-require golang.org/x/text v0.42.0 // indirect
14+require (
15+ github.com/stretchr/objx v0.5.3 // indirect
16+ go.yaml.in/yaml/v3 v3.0.5 // indirect
17+ golang.org/x/text v0.42.0 // indirect
18+)
M go.sum +6 -4 generated file: collapsed
A internal/edit/Clipboard_mock_test.go +158 -0
@@ -0,0 +1,158 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package edit
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockClipboard(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockClipboard {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockClipboard{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockClipboard is an autogenerated mock type for the Clipboard type
35+type MockClipboard struct {
36+ mock.Mock
37+}
38+
39+type MockClipboard_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockClipboard) EXPECT() *MockClipboard_Expecter {
44+ return &MockClipboard_Expecter{mock: &_m.Mock}
45+}
46+
47+// ReadText provides a mock function for the type MockClipboard
48+func (_mock *MockClipboard) ReadText(limit int) ([]byte, error) {
49+ ret := _mock.Called(limit)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for ReadText")
53+ }
54+
55+ var r0 []byte
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func(int) ([]byte, error)); ok {
58+ return returnFunc(limit)
59+ }
60+ if returnFunc, ok := ret.Get(0).(func(int) []byte); ok {
61+ r0 = returnFunc(limit)
62+ } else {
63+ if ret.Get(0) != nil {
64+ r0 = ret.Get(0).([]byte)
65+ }
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
68+ r1 = returnFunc(limit)
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// MockClipboard_ReadText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadText'
76+type MockClipboard_ReadText_Call struct {
77+ *mock.Call
78+}
79+
80+// ReadText is a helper method to define mock.On call
81+// - limit int
82+func (_e *MockClipboard_Expecter) ReadText(limit any) *MockClipboard_ReadText_Call {
83+ return &MockClipboard_ReadText_Call{Call: _e.mock.On("ReadText", limit)}
84+}
85+
86+func (_c *MockClipboard_ReadText_Call) Run(run func(limit int)) *MockClipboard_ReadText_Call {
87+ _c.Call.Run(func(args mock.Arguments) {
88+ var arg0 int
89+ if args[0] != nil {
90+ arg0 = args[0].(int)
91+ }
92+ run(
93+ arg0,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockClipboard_ReadText_Call) Return(bytes []byte, err error) *MockClipboard_ReadText_Call {
100+ _c.Call.Return(bytes, err)
101+ return _c
102+}
103+
104+func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(limit int) ([]byte, error)) *MockClipboard_ReadText_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// WriteText provides a mock function for the type MockClipboard
110+func (_mock *MockClipboard) WriteText(bytes []byte) error {
111+ ret := _mock.Called(bytes)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for WriteText")
115+ }
116+
117+ var r0 error
118+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
119+ r0 = returnFunc(bytes)
120+ } else {
121+ r0 = ret.Error(0)
122+ }
123+ return r0
124+}
125+
126+// MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
127+type MockClipboard_WriteText_Call struct {
128+ *mock.Call
129+}
130+
131+// WriteText is a helper method to define mock.On call
132+// - bytes []byte
133+func (_e *MockClipboard_Expecter) WriteText(bytes any) *MockClipboard_WriteText_Call {
134+ return &MockClipboard_WriteText_Call{Call: _e.mock.On("WriteText", bytes)}
135+}
136+
137+func (_c *MockClipboard_WriteText_Call) Run(run func(bytes []byte)) *MockClipboard_WriteText_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ var arg0 []byte
140+ if args[0] != nil {
141+ arg0 = args[0].([]byte)
142+ }
143+ run(
144+ arg0,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *MockClipboard_WriteText_Call) Return(err error) *MockClipboard_WriteText_Call {
151+ _c.Call.Return(err)
152+ return _c
153+}
154+
155+func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(bytes []byte) error) *MockClipboard_WriteText_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
A internal/edit/IME_mock_test.go +267 -0
@@ -0,0 +1,267 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package edit
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockIME creates a new instance of MockIME. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockIME(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockIME {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockIME{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockIME is an autogenerated mock type for the IME type
35+type MockIME struct {
36+ mock.Mock
37+}
38+
39+type MockIME_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockIME) EXPECT() *MockIME_Expecter {
44+ return &MockIME_Expecter{mock: &_m.Mock}
45+}
46+
47+// ContentType provides a mock function for the type MockIME
48+func (_mock *MockIME) ContentType(password bool, multiline bool) {
49+ _mock.Called(password, multiline)
50+ return
51+}
52+
53+// MockIME_ContentType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ContentType'
54+type MockIME_ContentType_Call struct {
55+ *mock.Call
56+}
57+
58+// ContentType is a helper method to define mock.On call
59+// - password bool
60+// - multiline bool
61+func (_e *MockIME_Expecter) ContentType(password any, multiline any) *MockIME_ContentType_Call {
62+ return &MockIME_ContentType_Call{Call: _e.mock.On("ContentType", password, multiline)}
63+}
64+
65+func (_c *MockIME_ContentType_Call) Run(run func(password bool, multiline bool)) *MockIME_ContentType_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 bool
68+ if args[0] != nil {
69+ arg0 = args[0].(bool)
70+ }
71+ var arg1 bool
72+ if args[1] != nil {
73+ arg1 = args[1].(bool)
74+ }
75+ run(
76+ arg0,
77+ arg1,
78+ )
79+ })
80+ return _c
81+}
82+
83+func (_c *MockIME_ContentType_Call) Return() *MockIME_ContentType_Call {
84+ _c.Call.Return()
85+ return _c
86+}
87+
88+func (_c *MockIME_ContentType_Call) RunAndReturn(run func(password bool, multiline bool)) *MockIME_ContentType_Call {
89+ _c.Run(run)
90+ return _c
91+}
92+
93+// CursorRect provides a mock function for the type MockIME
94+func (_mock *MockIME) CursorRect(x float64, y float64, w float64, h float64) {
95+ _mock.Called(x, y, w, h)
96+ return
97+}
98+
99+// MockIME_CursorRect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CursorRect'
100+type MockIME_CursorRect_Call struct {
101+ *mock.Call
102+}
103+
104+// CursorRect is a helper method to define mock.On call
105+// - x float64
106+// - y float64
107+// - w float64
108+// - h float64
109+func (_e *MockIME_Expecter) CursorRect(x any, y any, w any, h any) *MockIME_CursorRect_Call {
110+ return &MockIME_CursorRect_Call{Call: _e.mock.On("CursorRect", x, y, w, h)}
111+}
112+
113+func (_c *MockIME_CursorRect_Call) Run(run func(x float64, y float64, w float64, h float64)) *MockIME_CursorRect_Call {
114+ _c.Call.Run(func(args mock.Arguments) {
115+ var arg0 float64
116+ if args[0] != nil {
117+ arg0 = args[0].(float64)
118+ }
119+ var arg1 float64
120+ if args[1] != nil {
121+ arg1 = args[1].(float64)
122+ }
123+ var arg2 float64
124+ if args[2] != nil {
125+ arg2 = args[2].(float64)
126+ }
127+ var arg3 float64
128+ if args[3] != nil {
129+ arg3 = args[3].(float64)
130+ }
131+ run(
132+ arg0,
133+ arg1,
134+ arg2,
135+ arg3,
136+ )
137+ })
138+ return _c
139+}
140+
141+func (_c *MockIME_CursorRect_Call) Return() *MockIME_CursorRect_Call {
142+ _c.Call.Return()
143+ return _c
144+}
145+
146+func (_c *MockIME_CursorRect_Call) RunAndReturn(run func(x float64, y float64, w float64, h float64)) *MockIME_CursorRect_Call {
147+ _c.Run(run)
148+ return _c
149+}
150+
151+// Disable provides a mock function for the type MockIME
152+func (_mock *MockIME) Disable() {
153+ _mock.Called()
154+ return
155+}
156+
157+// MockIME_Disable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Disable'
158+type MockIME_Disable_Call struct {
159+ *mock.Call
160+}
161+
162+// Disable is a helper method to define mock.On call
163+func (_e *MockIME_Expecter) Disable() *MockIME_Disable_Call {
164+ return &MockIME_Disable_Call{Call: _e.mock.On("Disable")}
165+}
166+
167+func (_c *MockIME_Disable_Call) Run(run func()) *MockIME_Disable_Call {
168+ _c.Call.Run(func(args mock.Arguments) {
169+ run()
170+ })
171+ return _c
172+}
173+
174+func (_c *MockIME_Disable_Call) Return() *MockIME_Disable_Call {
175+ _c.Call.Return()
176+ return _c
177+}
178+
179+func (_c *MockIME_Disable_Call) RunAndReturn(run func()) *MockIME_Disable_Call {
180+ _c.Run(run)
181+ return _c
182+}
183+
184+// Enable provides a mock function for the type MockIME
185+func (_mock *MockIME) Enable() {
186+ _mock.Called()
187+ return
188+}
189+
190+// MockIME_Enable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enable'
191+type MockIME_Enable_Call struct {
192+ *mock.Call
193+}
194+
195+// Enable is a helper method to define mock.On call
196+func (_e *MockIME_Expecter) Enable() *MockIME_Enable_Call {
197+ return &MockIME_Enable_Call{Call: _e.mock.On("Enable")}
198+}
199+
200+func (_c *MockIME_Enable_Call) Run(run func()) *MockIME_Enable_Call {
201+ _c.Call.Run(func(args mock.Arguments) {
202+ run()
203+ })
204+ return _c
205+}
206+
207+func (_c *MockIME_Enable_Call) Return() *MockIME_Enable_Call {
208+ _c.Call.Return()
209+ return _c
210+}
211+
212+func (_c *MockIME_Enable_Call) RunAndReturn(run func()) *MockIME_Enable_Call {
213+ _c.Run(run)
214+ return _c
215+}
216+
217+// Surrounding provides a mock function for the type MockIME
218+func (_mock *MockIME) Surrounding(text string, cursor int, anchor int) {
219+ _mock.Called(text, cursor, anchor)
220+ return
221+}
222+
223+// MockIME_Surrounding_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Surrounding'
224+type MockIME_Surrounding_Call struct {
225+ *mock.Call
226+}
227+
228+// Surrounding is a helper method to define mock.On call
229+// - text string
230+// - cursor int
231+// - anchor int
232+func (_e *MockIME_Expecter) Surrounding(text any, cursor any, anchor any) *MockIME_Surrounding_Call {
233+ return &MockIME_Surrounding_Call{Call: _e.mock.On("Surrounding", text, cursor, anchor)}
234+}
235+
236+func (_c *MockIME_Surrounding_Call) Run(run func(text string, cursor int, anchor int)) *MockIME_Surrounding_Call {
237+ _c.Call.Run(func(args mock.Arguments) {
238+ var arg0 string
239+ if args[0] != nil {
240+ arg0 = args[0].(string)
241+ }
242+ var arg1 int
243+ if args[1] != nil {
244+ arg1 = args[1].(int)
245+ }
246+ var arg2 int
247+ if args[2] != nil {
248+ arg2 = args[2].(int)
249+ }
250+ run(
251+ arg0,
252+ arg1,
253+ arg2,
254+ )
255+ })
256+ return _c
257+}
258+
259+func (_c *MockIME_Surrounding_Call) Return() *MockIME_Surrounding_Call {
260+ _c.Call.Return()
261+ return _c
262+}
263+
264+func (_c *MockIME_Surrounding_Call) RunAndReturn(run func(text string, cursor int, anchor int)) *MockIME_Surrounding_Call {
265+ _c.Run(run)
266+ return _c
267+}
M internal/edit/edit_test.go +18 -24
@@ -4,24 +4,10 @@ import (
44 "strings"
55 "testing"
66 "unicode/utf8"
7-)
8-
9-type memoryClipboard struct{ data []byte }
107
11-func (m *memoryClipboard) ReadText(limit int) ([]byte, error) { return m.data, nil }
12-func (m *memoryClipboard) WriteText(v []byte) error { m.data = append([]byte(nil), v...); return nil }
13-
14-type fakeIME struct {
15- enabled, disabled int
16- surrounding string
17- cursor, anchor int
18-}
8+ "github.com/stretchr/testify/mock"
9+)
1910
20-func (f *fakeIME) Enable() { f.enabled++ }
21-func (f *fakeIME) Disable() { f.disabled++ }
22-func (f *fakeIME) Surrounding(v string, c, a int) { f.surrounding = v; f.cursor = c; f.anchor = a }
23-func (f *fakeIME) CursorRect(x, y, w, h float64) {}
24-func (f *fakeIME) ContentType(password, multiline bool) {}
2511 func TestGraphemesAndIME(t *testing.T) {
2612 s := State{}
2713 s.Sync("e\u0301👩<U+200D>💻a")
@@ -46,33 +32,41 @@ func TestGraphemesAndIME(t *testing.T) {
4632 if s.Preedit != "候補" || s.Value != "éa" {
4733 t.Fatal(s)
4834 }
49- ime := &fakeIME{}
35+ ime := NewMockIME(t)
36+ ime.EXPECT().Enable().Once()
37+ ime.EXPECT().Surrounding("éa", mock.Anything, mock.Anything).Maybe()
38+ ime.EXPECT().CursorRect(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe()
39+ ime.EXPECT().ContentType(false, false).Once()
40+ ime.EXPECT().Disable().Once()
5041 s.Focus(ime, false, false)
5142 s.Blur(ime)
52- if ime.enabled != 1 || ime.disabled != 1 || s.Preedit != "" {
53- t.Fatal(s, ime)
43+ if s.Preedit != "" {
44+ t.Fatal(s)
5445 }
5546 }
5647 func TestClipboardAndMask(t *testing.T) {
5748 s := State{}
5849 s.Sync("s3crét")
5950 s.Select(0, len(s.Value))
60- c := &memoryClipboard{}
61- if err := s.Copy(c, true); err != nil || len(c.data) != 0 {
51+ c := NewMockClipboard(t) // a masked copy must not write
52+ if err := s.Copy(c, true); err != nil {
6253 t.Fatal(err)
6354 }
6455 if s.Mask() != "••••••" {
6556 t.Fatal(s.Mask())
6657 }
67- c.data = []byte{0xff}
58+ read := func(data []byte) {
59+ c.EXPECT().ReadText(mock.Anything).Return(data, nil).Once()
60+ }
61+ read([]byte{0xff})
6862 if changed, _ := s.Paste(c); changed {
6963 t.Fatal(s)
7064 }
71- c.data = []byte(strings.Repeat("x", MaxClipboardBytes+1))
65+ read([]byte(strings.Repeat("x", MaxClipboardBytes+1)))
7266 if changed, _ := s.Paste(c); changed {
7367 t.Fatal(s)
7468 }
75- c.data = []byte("ok")
69+ read([]byte("ok"))
7670 if changed, _ := s.Paste(c); !changed || s.Value != "ok" {
7771 t.Fatal(s)
7872 }
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-}
M internal/presentation/buffers/buffers.go +2 -2
@@ -1,5 +1,5 @@
1-// Package buffers models Wayland/Vulkan buffer ownership without GPU dependencies.
2-// The caller serializes events from the renderer, the Wayland dispatch loop, and DRM.
1+// Package buffers models buffer ownership between the renderer and the
2+// compositor without GPU dependencies. The caller serializes all events.
33 package buffers
44
55 import (
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 -951
@@ -1,951 +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/drmsyncobj"
28- "golang.org/x/sys/unix"
29-)
30-
31-type Slot struct {
32- Buffer *buffers.Buffer
33- Image *vkdevice.Image
34- Frame *vkdevice.Frame
35- First bool
36- Wait *vkdevice.BinaryFence
37- PendingWait *vkdevice.BinaryFence
38- WaitInFlight bool
39- ReadbackFrame uint64
40- ReadbackPending bool
41- ReleaseHandle uint32
42- ReleaseTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline
43-}
44-type Session struct {
45- Window *wayland.Window
46- Device *vkdevice.Device
47- Node *syncobj.Node
48- Pool *buffers.Pool
49- Pipeline *vkdevice.Pipeline
50- ListPipeline *vkdevice.Pipeline
51- Preparer *render.Preparer
52- list render.ListBuffer // owner-loop scratch; see tickFrame
53- Slots map[uint64]*Slot
54- retiredPipelines []retiredPipelines
55- Acquire uint32
56- AcquireTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline
57- Width, Height int32
58- Transparent bool
59- trace *os.File
60- traceBuffer *bufio.Writer
61- debugDir string
62- timings *os.File
63- timingBuffer *bufio.Writer
64- frame uint64
65- debugFrames uint64
66- debugDropped uint64
67- readbacks chan readbackJob
68- readbackDone chan struct{}
69- debugWritten chan uint64
70- // Written only by readbackWorker; read after readbackDone.
71- debugWriteErrors uint64
72- debugLastWriteErr string
73- fdStart, fdPeak int
74- framePending bool
75- waitCtx context.Context
76- waitCancel context.CancelFunc
77- waiters sync.WaitGroup
78- releases chan releaseEvent
79- wake chan<- struct{} // optional: pinged after a release is queued
80-}
81-
82-type retiredPipelines struct {
83- generation uint64
84- rect, list *vkdevice.Pipeline
85-}
86-
87-type readbackJob struct {
88- frame uint64
89- image *image.NRGBA
90-}
91-
92-const maxDebugPNGs = 16
93-const readbackQueueSize = 2
94-
95-func captureBudget(saved, dropped uint64, pending int, queued int, limit uint64) bool {
96- return saved+dropped+uint64(pending+queued) < limit
97-}
98-
99-// offerReadback never blocks the state owner. The worker owns the copied image.
100-func offerReadback(queue chan readbackJob, job readbackJob) bool {
101- select {
102- case queue <- job:
103- return true
104- default:
105- return false
106- }
107-}
108-
109-// readbackWorker owns debugWriteErrors until readbackDone is closed.
110-func (s *Session) readbackWorker() {
111- defer close(s.readbackDone)
112- for job := range s.readbacks {
113- path := filepath.Join(s.debugDir, fmt.Sprintf("readback-%06d.png", job.frame))
114- vkdevice.SwizzleBGRA(job.image)
115- if err := writePNG(path, job.image); err != nil {
116- s.debugWriteErrors++
117- s.debugLastWriteErr = err.Error()
118- continue
119- }
120- s.debugWritten <- job.frame
121- }
122-}
123-
124-func writePNG(path string, img image.Image) error {
125- f, err := os.Create(path)
126- if err != nil {
127- return err
128- }
129- err = png.Encode(f, img)
130- if closeErr := f.Close(); err == nil {
131- err = closeErr
132- }
133- return err
134-}
135-
136-type releaseEvent struct {
137- id, point uint64
138- err error
139-}
140-
141-// traceState is entirely disabled when NEFERGUI_DEBUG_DIR is unset.
142-func (s *Session) traceState(b *buffers.Buffer, event string) {
143- if s.trace == nil || b == nil {
144- return
145- }
146- _ = json.NewEncoder(s.traceBuffer).Encode(struct {
147- Time time.Time `json:"time"`
148- Frame, Buffer, Generation uint64
149- State buffers.State
150- Event string
151- AcquirePoint, ReleasePoint uint64
152- WlReleased, PointSignaled bool
153- }{time.Now(), s.frame, b.ID, b.Generation, b.State, event, b.AcquirePoint, b.ReleasePoint, b.WlReleased, b.PointSignaled})
154-}
155-
156-// Open opens a default xdg-toplevel session.
157-func Open(name string, width, height int32, transparent bool) (*Session, error) {
158- return OpenWithOptions(context.Background(), name, width, height, transparent, wayland.SurfaceOptions{})
159-}
160-
161-// OpenWithOptions opens a session whose surface role and initial input region
162-// are selected by opts; the zero value is identical to Open. Wayland startup
163-// is abandoned when ctx is cancelled; ctx is not retained after it returns.
164-func OpenWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts wayland.SurfaceOptions) (_ *Session, err error) {
165- w, err := wayland.ConnectWithOptions(ctx, name, width, height, transparent, opts)
166- if err != nil {
167- return nil, err
168- }
169- return OpenWindow(w, transparent)
170-}
171-
172-// OpenWindow builds the Vulkan/DMA-BUF/explicit-sync path for a window that is
173-// already configured and has completed dmabuf feedback (a Connection sibling).
174-// On error the window is closed. The session owns w from here on.
175-func OpenWindow(w *wayland.Window, transparent bool) (_ *Session, err error) {
176- if !w.Configured || !w.FeedbackDone {
177- _ = w.Close()
178- return nil, errors.New("session: window is not configured with complete dmabuf feedback")
179- }
180- s := &Session{Window: w, Transparent: transparent, Slots: make(map[uint64]*Slot)}
181- if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" {
182- s.debugDir = dir
183- if err = os.MkdirAll(dir, 0700); err != nil {
184- _ = w.Close()
185- return nil, err
186- }
187- s.trace, err = os.Create(filepath.Join(dir, "buffer-lifecycle.jsonl"))
188- if err != nil {
189- _ = w.Close()
190- return nil, err
191- }
192- s.timings, err = os.Create(filepath.Join(dir, "frame-timings.jsonl"))
193- if err != nil {
194- _ = s.trace.Close()
195- _ = w.Close()
196- return nil, err
197- }
198- s.traceBuffer = bufio.NewWriterSize(s.trace, 64*1024)
199- s.timingBuffer = bufio.NewWriterSize(s.timings, 64*1024)
200- s.readbacks = make(chan readbackJob, readbackQueueSize)
201- s.readbackDone = make(chan struct{})
202- s.debugWritten = make(chan uint64, maxDebugPNGs)
203- go s.readbackWorker()
204- }
205- defer func() {
206- if err != nil {
207- err = errors.Join(err, s.Close())
208- }
209- }()
210- s.Preparer, err = render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages)
211- if err != nil {
212- return nil, err
213- }
214- s.Device, err = vkdevice.Open(vkdevice.DeviceIdentity(w.MainDevice))
215- if err != nil {
216- return nil, err
217- }
218- s.Node, err = syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", s.Device.Render.Minor)))
219- if err != nil {
220- return nil, err
221- }
222- if s.Acquire, err = s.Node.Create(); err != nil {
223- return nil, err
224- }
225- if s.AcquireTimeline, err = s.importTimeline(s.Acquire); err != nil {
226- return nil, err
227- }
228- s.Pool, err = buffers.New(3)
229- if err != nil {
230- return nil, err
231- }
232- if err = s.newGeneration(); err != nil {
233- return nil, err
234- }
235- return s, nil
236-}
237-
238-func (s *Session) importTimeline(handle uint32) (*drmsyncobj.WpLinuxDrmSyncobjTimeline, error) {
239- fd, err := s.Node.ExportTimeline(handle)
240- if err != nil {
241- return nil, err
242- }
243- // WLTurbo closes the sent descriptor on successful ImportTimeline; on
244- // transport failure its ownership remains with this caller.
245- timeline, err := s.Window.SyncManager.ImportTimeline(fd)
246- if err != nil {
247- _ = unix.Close(fd)
248- return nil, err
249- }
250- return timeline, nil
251-}
252-
253-func (s *Session) newGeneration() error {
254- width, height, err := s.Window.PhysicalSize()
255- if err != nil {
256- return err
257- }
258- mods, err := s.Device.ExportableModifiers()
259- if err != nil {
260- return err
261- }
262- selected, err := vkdevice.ChooseModifier(s.Window.Tranches, s.Transparent, mods)
263- if err != nil {
264- return err
265- }
266- pipeline, err := s.Device.NewRectPipeline(width, height)
267- if err != nil {
268- return err
269- }
270- listPipeline, err := s.Device.NewListPipeline(width, height)
271- if err != nil {
272- pipeline.Close()
273- return err
274- }
275- // A pipeline may still be referenced by submissions for the previous
276- // generation. retire closes it only after all old slots have retired.
277- if s.Pipeline != nil {
278- s.retiredPipelines = append(s.retiredPipelines, retiredPipelines{
279- generation: s.Pool.Generation - 1, rect: s.Pipeline, list: s.ListPipeline,
280- })
281- }
282- s.Pipeline = pipeline
283- s.ListPipeline = listPipeline
284- s.Width, s.Height = width, height
285- for _, b := range s.Pool.Buffers {
286- if b.Generation != s.Pool.Generation {
287- continue
288- }
289- img, e := s.Device.AllocateImage(width, height, selected, s.Window.Dmabuf)
290- if e != nil {
291- return e
292- }
293- frame, e := s.Device.NewFrame(img)
294- if e == nil && s.debugDir != "" {
295- frame.Readback, e = s.Device.NewReadback(width, height)
296- }
297- if e != nil {
298- if frame != nil {
299- frame.Close()
300- }
301- img.Close()
302- return e
303- }
304- slot := &Slot{Buffer: b, Image: img, Frame: frame, First: true}
305- slot.ReleaseHandle, e = s.Node.Create()
306- if e != nil {
307- frame.Close()
308- img.Close()
309- return e
310- }
311- slot.ReleaseTimeline, e = s.importTimeline(slot.ReleaseHandle)
312- if e != nil {
313- _ = s.Node.Destroy(slot.ReleaseHandle)
314- frame.Close()
315- img.Close()
316- return e
317- }
318- s.Slots[b.ID] = slot
319- img.Buffer.OnRelease(func() { s.Window.BufferReleased(b.ID) })
320- }
321- return nil
322-}
323-
324-// beginReady does not change Pool ownership until the previous GPU submission
325-// has completed. An unready available buffer is retried on a bounded wakeup.
326-func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {
327- waiting := false
328- for _, b := range pool.Buffers {
329- if b.Generation != pool.Generation || b.State != buffers.Available {
330- continue
331- }
332- slot := slots[b.ID]
333- if slot == nil {
334- return nil, nil, false, fmt.Errorf("missing slot %d", b.ID)
335- }
336- ok, err := ready(slot)
337- if err != nil {
338- return nil, nil, false, err
339- }
340- if !ok {
341- waiting = true
342- continue
343- }
344- b.State = buffers.Rendering
345- b.WlReleased, b.PointSignaled = false, false
346- return b, slot, false, nil
347- }
348- return nil, nil, waiting, nil
349-}
350-
351-// Tick presents at most one frame. The caller drives the Wayland dispatch
352-// loop; a frame callback only schedules Tick, never makes an image reusable.
353-func (s *Session) Tick(rect vkdevice.Rect) (bool, error) {
354- return s.tick(&rect, nil, nil, nil)
355-}
356-
357-// TickList presents ordered instanced quads with the same ownership gates as Tick.
358-func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload) (bool, error) {
359- var batches []vkdevice.Batch
360- if len(instances) > 0 {
361- batches = []vkdevice.Batch{{Count: uint32(len(instances))}}
362- }
363- return s.tick(nil, instances, uploads, batches)
364-}
365-
366-// tickFrame lists and submits one prepared frame on the owner loop. The list
367-// buffer's slices are borrowed only for this call: tick copies instances into
368-// mapped GPU memory and resolve copies batches before returning, and no pending
369-// GPU work or retained frame refers to them (a blocked frame is re-listed from
370-// its render.Frame on retry). Release then drops borrowed image references.
371-func (s *Session) tickFrame(frame render.Frame) (bool, error) {
372- defer s.list.Release()
373- instances, batches, stats := s.list.List(frame)
374- if len(stats.Skipped) != 0 {
375- return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
376- }
377- return s.tick(nil, instances, frame.Uploads, batches)
378-}
379-
380-func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
381- started := time.Now()
382- if s.Window.Dirty {
383- w, h, err := s.Window.PhysicalSize()
384- if err != nil {
385- return false, err
386- }
387- if w != s.Width || h != s.Height {
388- if err = s.Pool.Resize(3); err != nil {
389- return false, err
390- }
391- if err = s.newGeneration(); err != nil {
392- return false, err
393- }
394- }
395- s.Window.Dirty = false
396- }
397- if err := s.retire(); err != nil {
398- return false, err
399- }
400- if !s.Window.FrameReady {
401- return false, nil
402- }
403- b, slot, waiting, err := beginReady(s.Pool, s.Slots, func(slot *Slot) (bool, error) { return slot.Frame.Ready() })
404- if err != nil {
405- return false, err
406- }
407- if b == nil {
408- s.framePending = waiting
409- return false, nil
410- }
411- s.framePending = false
412- s.traceState(b, "begin")
413- if err := s.saveReadback(slot); err != nil {
414- return false, err
415- }
416- if slot.Wait != nil && slot.WaitInFlight {
417- slot.Wait.Close()
418- slot.Wait = nil
419- slot.WaitInFlight = false
420- }
421- capture := s.wantReadback()
422- if rect != nil {
423- err = slot.Frame.Record(slot.Image, s.Pipeline, s.Width, s.Height, *rect, slot.First, capture)
424- } else {
425- err = slot.Frame.RecordList(slot.Image, s.ListPipeline, s.Width, s.Height, instances, uploads, batches, slot.First, capture)
426- }
427- if err != nil {
428- return false, err
429- }
430- if err = slot.Frame.Submit(slot.Wait); err != nil {
431- return false, err
432- }
433- // The render module keeps uploads owed until this acknowledgement.
434- s.Preparer.Submitted(uploads)
435- if slot.Wait != nil {
436- slot.WaitInFlight = true
437- }
438- // Keep the imported release-wait semaphore alive until GPU completion.
439- slot.First = false
440- fd, err := slot.Frame.Signal.Export()
441- if err != nil {
442- return false, err
443- }
444- acquire, err := s.Pool.Acquire(b)
445- if err != nil {
446- _ = unix.Close(fd)
447- return false, err
448- }
449- if err = s.Node.AcquirePoint(fd, s.Acquire, acquire); err != nil {
450- _ = unix.Close(fd)
451- return false, err
452- }
453- s.traceState(b, "acquire_point_exported")
454- release, err := s.Pool.Commit(b)
455- if err != nil {
456- return false, err
457- }
458- if err = s.Window.Present(slot.Image.Buffer, s.AcquireTimeline, slot.ReleaseTimeline, acquire, release); err != nil {
459- return false, err
460- }
461- s.traceState(b, "committed")
462- if err = s.Pool.Own(b); err != nil {
463- return false, err
464- }
465- s.traceState(b, "compositor_owns")
466- if capture {
467- slot.ReadbackFrame = s.frame
468- slot.ReadbackPending = true
469- }
470- if s.timings != nil {
471- if len(s.Slots) > 6 {
472- return false, fmt.Errorf("unbounded image generations: %d", len(s.Slots))
473- }
474- fds, _ := os.ReadDir("/proc/self/fd")
475- if len(fds) > s.fdPeak {
476- s.fdPeak = len(fds)
477- }
478- _ = 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()})
479- }
480- s.startReleaseWait(slot, release)
481- s.frame++
482- return true, nil
483-}
484-
485-func (s *Session) wantReadback() bool {
486- if s.readbacks == nil {
487- return false
488- }
489- pending := 0
490- for _, slot := range s.Slots {
491- if slot.ReadbackPending {
492- pending++
493- }
494- }
495- return captureBudget(s.debugFrames, s.debugDropped, pending, len(s.readbacks), maxDebugPNGs)
496-}
497-
498-// saveReadback is only called for a completed GPU submission; the state owner
499-// copies into owned memory and hands the PNG encoding to the bounded worker.
500-func (s *Session) saveReadback(slot *Slot) error {
501- if !slot.ReadbackPending || slot.Frame.Readback == nil {
502- return nil
503- }
504- // Do not map a full-size image while the encoder cannot accept it.
505- if len(s.readbacks) == cap(s.readbacks) {
506- slot.ReadbackPending = false
507- s.debugDropped++
508- return nil
509- }
510- img, err := slot.Frame.Readback.Copy()
511- if err != nil {
512- return err
513- }
514- slot.ReadbackPending = false
515- if offerReadback(s.readbacks, readbackJob{slot.ReadbackFrame, img}) {
516- s.debugFrames++
517- } else {
518- s.debugDropped++
519- }
520- return nil
521-}
522-
523-// startReleaseWait never mutates Pool or Vulkan state from the waiter.
524-func (s *Session) startReleaseWait(slot *Slot, point uint64) {
525- if s.waitCancel == nil {
526- return
527- }
528- ctx := s.waitCtx
529- id, handle := slot.Buffer.ID, slot.ReleaseHandle
530- s.waiters.Add(1)
531- go func() {
532- defer s.waiters.Done()
533- for {
534- if ctx.Err() != nil {
535- return
536- }
537- ready, err := s.Node.WaitPoint(handle, point, 20*time.Millisecond)
538- if err != nil || ready {
539- select {
540- case s.releases <- releaseEvent{id: id, point: point, err: err}:
541- if s.wake != nil {
542- select {
543- case s.wake <- struct{}{}:
544- default:
545- }
546- }
547- case <-ctx.Done():
548- }
549- return
550- }
551- }
552- }()
553-}
554-
555-func (s *Session) release(ev releaseEvent) error {
556- if ev.err != nil {
557- return ev.err
558- }
559- slot := s.Slots[ev.id]
560- if slot == nil {
561- return nil
562- }
563- b := slot.Buffer
564- // An older completion cannot authorize a newer commit of the same image.
565- if b.ReleasePoint != ev.point || b.State != buffers.CompositorOwned {
566- return nil
567- }
568- if err := s.Pool.Signal(b, ev.point); err != nil {
569- return err
570- }
571- s.traceState(b, "release_point_signaled")
572- if b.Generation == s.Pool.Generation {
573- fd, err := s.Node.ReleaseFence(slot.ReleaseHandle, ev.point)
574- if err != nil {
575- return err
576- }
577- wait, err := s.Device.NewBinaryFence(false)
578- if err != nil {
579- _ = unix.Close(fd)
580- return err
581- }
582- if err = wait.ImportTemporary(fd); err != nil {
583- _ = unix.Close(fd)
584- wait.Close()
585- return err
586- }
587- installed, e := installReleaseWait(slot, wait, slot.Frame.Ready)
588- if e != nil {
589- wait.Close()
590- return e
591- }
592- if !installed {
593- return nil
594- }
595- if err = s.Pool.Reuse(b, true); err != nil {
596- return err
597- }
598- s.traceState(b, "available")
599- }
600- return s.retire()
601-}
602-
603-// installReleaseWait holds the new semaphore until the old submission is done.
604-// Only the state owner calls it. On an unready fence it leaves Pool Released.
605-func installReleaseWait(slot *Slot, next *vkdevice.BinaryFence, ready func() (bool, error)) (bool, error) {
606- if slot.Wait != nil && slot.WaitInFlight {
607- ok, err := ready()
608- if err != nil {
609- return false, err
610- }
611- if !ok {
612- slot.PendingWait = next
613- return false, nil
614- }
615- }
616- if slot.Wait != nil {
617- slot.Wait.Close()
618- }
619- slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false
620- return true, nil
621-}
622-
623-// finishPending runs only on the state-owner loop; never waits on a GPU fence.
624-func (s *Session) finishPending() (bool, error) {
625- pending := false
626- for _, slot := range s.Slots {
627- if slot.PendingWait == nil {
628- continue
629- }
630- ready, err := slot.Frame.Ready()
631- if err != nil {
632- return false, err
633- }
634- if !ready {
635- pending = true
636- continue
637- }
638- if slot.Wait != nil {
639- slot.Wait.Close()
640- }
641- slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
642- if err := s.Pool.Reuse(slot.Buffer, true); err != nil {
643- return false, err
644- }
645- s.traceState(slot.Buffer, "available")
646- }
647- return pending, nil
648-}
649-
650-func (s *Session) retire() error {
651- for _, slot := range s.Slots {
652- b := slot.Buffer
653- if !s.Pool.Retirable(b) {
654- continue
655- }
656- ready, err := slot.Frame.Ready()
657- if err != nil {
658- return err
659- }
660- if !ready {
661- continue
662- }
663- if err := s.saveReadback(slot); err != nil {
664- return err
665- }
666- slot.Frame.Close()
667- if slot.PendingWait != nil {
668- slot.PendingWait.Close()
669- }
670- if slot.Wait != nil {
671- slot.Wait.Close()
672- }
673- slot.Image.Close()
674- _ = slot.ReleaseTimeline.Destroy()
675- _ = s.Node.Destroy(slot.ReleaseHandle)
676- s.traceState(b, "retired")
677- delete(s.Slots, b.ID)
678- if err = s.Pool.Remove(b); err != nil {
679- return err
680- }
681- }
682- for i := 0; i < len(s.retiredPipelines); {
683- old := s.retiredPipelines[i]
684- live := false
685- for _, slot := range s.Slots {
686- if slot.Buffer.Generation == old.generation {
687- live = true
688- break
689- }
690- }
691- if live {
692- i++
693- continue
694- }
695- old.rect.Close()
696- old.list.Close()
697- s.retiredPipelines = append(s.retiredPipelines[:i], s.retiredPipelines[i+1:]...)
698- }
699- return nil
700-}
701-
702-func (s *Session) Run(ctx context.Context, frames int, draw func(int) vkdevice.Rect) (int, error) {
703- return s.RunWithCommit(ctx, frames, draw, nil)
704-}
705-
706-// RunWithCommit calls committed after each successfully committed frame.
707-// The optional hook is for the example's capture request; it never gates reuse.
708-func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int) vkdevice.Rect, committed func(int) error) (int, error) {
709- if draw == nil {
710- return 0, fmt.Errorf("nil draw function")
711- }
712- return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)
713-}
714-
715-// RunPreparedListWithCommit prepares uploads on every attempt. Preparer keeps
716-// them owed until a submission, so a skipped attempt is resent by the next one.
717-func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {
718- if draw == nil {
719- return 0, fmt.Errorf("nil draw function")
720- }
721- return s.runWithCommit(ctx, frames, func(i int) (bool, error) {
722- frame, err := draw(i)
723- if err != nil {
724- return false, err
725- }
726- return s.tickFrame(frame)
727- }, committed)
728-}
729-
730-// RunListWithCommit drives the list renderer using the existing frame loop.
731-func (s *Session) RunListWithCommit(ctx context.Context, frames int, draw func(int) []vkdevice.Instance, committed func(int) error) (int, error) {
732- if draw == nil {
733- return 0, fmt.Errorf("nil draw function")
734- }
735- return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.TickList(draw(i), nil) }, committed)
736-}
737-
738-func (s *Session) runWithCommit(ctx context.Context, frames int, tick func(int) (bool, error), committed func(int) error) (int, error) {
739- if frames < 0 {
740- return 0, fmt.Errorf("invalid frame count")
741- }
742- if s.waitCancel != nil {
743- return 0, fmt.Errorf("session already running")
744- }
745- s.waitCtx, s.waitCancel = context.WithCancel(ctx)
746- s.releases = make(chan releaseEvent, 128)
747- if s.debugDir != "" {
748- fds, _ := os.ReadDir("/proc/self/fd")
749- s.fdStart = len(fds)
750- s.fdPeak = s.fdStart
751- }
752- wlEvents := s.Window.StartReader()
753- count := 0
754- defer func() { s.waitCancel(); s.waiters.Wait() }()
755- for !s.Window.Closed && (frames == 0 || count < frames) {
756- if err := ctx.Err(); err != nil {
757- return count, err
758- }
759- pending, err := s.finishPending()
760- if err != nil {
761- return count, err
762- }
763- if ok, err := tick(count); err != nil {
764- return count, err
765- } else if ok {
766- count++
767- if committed != nil {
768- if err := committed(count); err != nil {
769- return count, err
770- }
771- }
772- continue
773- }
774- var retry <-chan time.Time
775- if pending || s.framePending {
776- retry = time.After(5 * time.Millisecond)
777- }
778- select {
779- case <-ctx.Done():
780- return count, ctx.Err()
781- case <-retry:
782- case ev := <-wlEvents:
783- if ev.Kind == wayland.BufferRelease {
784- if slot := s.Slots[ev.BufferID]; slot != nil {
785- if err := s.Pool.Release(slot.Buffer); err != nil {
786- return count, err
787- }
788- s.traceState(slot.Buffer, "wl_buffer.release")
789- }
790- } else if err := s.Window.Apply(ev); err != nil {
791- return count, err
792- }
793- case ev := <-s.releases:
794- if err := s.release(ev); err != nil {
795- return count, err
796- }
797- }
798- }
799- if s.Device != nil && s.Device.Validation != nil && len(s.Device.Validation.Messages()) > 0 {
800- return count, fmt.Errorf("%d Vulkan validation errors: %v", len(s.Device.Validation.Messages()), s.Device.Validation.Messages())
801- }
802- return count, nil
803-}
804-
805-// Close detaches and destroys the explicit-sync surface, traces release points
806-// for at most 200 ms, waits for our GPU, then frees every client allocation.
807-// Compositor imports keep independent DMA-BUF kernel references.
808-// It returns errors from writing requested debug artifacts; allocations are
809-// freed regardless.
810-func (s *Session) Close() (err error) {
811- if s == nil {
812- return nil
813- }
814- if s.trace != nil {
815- defer func() { err = errors.Join(err, s.traceBuffer.Flush(), s.trace.Close()) }()
816- }
817- if s.timings != nil {
818- defer func() { err = errors.Join(err, s.timingBuffer.Flush(), s.timings.Close()) }()
819- }
820- if s.readbacks != nil {
821- defer func() {
822- if s.readbacks != nil {
823- close(s.readbacks)
824- <-s.readbackDone
825- }
826- }()
827- }
828- if s.Window != nil && s.Window.Surface != nil {
829- // A null-buffer commit is a protocol error on an ext-session-lock
830- // surface; its destruction hides it instead.
831- if !s.Window.IsLock() {
832- _ = s.Window.Surface.Attach(nil, 0, 0)
833- _ = s.Window.Surface.Commit()
834- }
835- _ = s.Window.DestroySyncSurface()
836- }
837- // Drain detach/release points for tracing, not for freeing allocations. The
838- // reader and waiters continue posting events, but the loop owns all state.
839- if s.waitCancel != nil && s.Pool != nil {
840- deadline := time.NewTimer(200 * time.Millisecond)
841- defer deadline.Stop()
842- for {
843- busy := false
844- for _, slot := range s.Slots {
845- if slot.Buffer.State != buffers.Available && slot.Buffer.State != buffers.Released {
846- busy = true
847- break
848- }
849- }
850- if !busy {
851- break
852- }
853- select {
854- case ev := <-s.releases:
855- _ = s.release(ev)
856- case ev := <-s.Window.ReaderEvents(): // nil on a shared connection
857- if ev.Kind == wayland.BufferRelease {
858- if slot := s.Slots[ev.BufferID]; slot != nil {
859- _ = s.Pool.Release(slot.Buffer)
860- }
861- } else if ev.Kind != wayland.TransportError {
862- _ = s.Window.Apply(ev)
863- }
864- case <-deadline.C:
865- goto stopWaiters
866- }
867- }
868- }
869-stopWaiters:
870- if s.waitCancel != nil {
871- s.waitCancel()
872- s.waiters.Wait()
873- s.waitCancel = nil
874- }
875- // The client GPU's completion is independent from compositor release.
876- if s.Device != nil && s.Device.Logical != 0 && s.Device.Dispatch != nil {
877- _ = s.Device.Dispatch.DeviceWaitIdle(s.Device.Logical)
878- }
879- if s.Pool != nil {
880- _ = s.retire()
881- }
882- for _, slot := range s.Slots {
883- // GPU work must finish before destroying a semaphore it waits on.
884- if slot.ReadbackPending {
885- _ = s.saveReadback(slot)
886- }
887- if slot.Frame != nil {
888- slot.Frame.Close()
889- }
890- if slot.Wait != nil {
891- slot.Wait.Close()
892- }
893- if slot.PendingWait != nil {
894- slot.PendingWait.Close()
895- }
896- if slot.ReleaseTimeline != nil {
897- _ = slot.ReleaseTimeline.Destroy()
898- }
899- // The exported DMA-BUF remains alive in the compositor independently.
900- if slot.Image != nil {
901- slot.Image.Close()
902- }
903- if s.Node != nil && slot.ReleaseHandle != 0 {
904- _ = s.Node.Destroy(slot.ReleaseHandle)
905- }
906- }
907- if s.AcquireTimeline != nil {
908- _ = s.AcquireTimeline.Destroy()
909- }
910- if s.Window != nil {
911- _ = s.Window.Close()
912- }
913- if s.Node != nil {
914- if s.Acquire != 0 {
915- _ = s.Node.Destroy(s.Acquire)
916- }
917- _ = s.Node.Close()
918- }
919- for _, old := range s.retiredPipelines {
920- old.rect.Close()
921- old.list.Close()
922- }
923- s.retiredPipelines = nil
924- if s.Pipeline != nil {
925- s.Pipeline.Close()
926- }
927- if s.ListPipeline != nil {
928- s.ListPipeline.Close()
929- }
930- if s.Device != nil {
931- s.Device.Close()
932- }
933- if s.debugDir != "" {
934- // Wait for the worker before reporting completed PNGs.
935- if s.readbacks != nil {
936- close(s.readbacks)
937- <-s.readbackDone
938- s.readbacks = nil
939- }
940- runtime.GC()
941- fds, _ := os.ReadDir("/proc/self/fd")
942- 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}, "", " ")
943- if e == nil {
944- e = os.WriteFile(filepath.Join(s.debugDir, "resource-summary.json"), append(data, '\n'), 0600)
945- }
946- if e != nil {
947- err = errors.Join(err, fmt.Errorf("session: resource summary: %w", e))
948- }
949- }
950- return err
951-}
M internal/presentation/session/session_test.go +30 -0
@@ -3,8 +3,10 @@
33 package session
44
55 import (
6+ "errors"
67 "testing"
78
9+ "github.com/bnema/nefergui/internal/presentation/buffers"
810 "github.com/bnema/nefergui/internal/presentation/vkdevice"
911 )
1012
@@ -24,3 +26,31 @@ func TestInstallReleaseWaitDefersUnreadySubmission(t *testing.T) {
2426 t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot)
2527 }
2628 }
29+
30+// A resize while a release wait is outstanding leaves old-generation slots to
31+// retire: finishPending must not touch them, even if their frame is not ready.
32+func TestTargetFinishPendingSkipsOldGenerations(t *testing.T) {
33+ pool, _ := buffers.New(1)
34+ old := pool.Buffers[0]
35+ if err := pool.Resize(1); err != nil {
36+ t.Fatal(err)
37+ }
38+ // A nil Frame would panic if the old slot were examined.
39+ tg := &Target{Pool: pool, Slots: map[uint64]*Slot{old.ID: {Buffer: old, PendingWait: &vkdevice.BinaryFence{}}}}
40+ pending, err := tg.finishPending()
41+ if err != nil || pending {
42+ t.Fatalf("pending=%v err=%v", pending, err)
43+ }
44+ if old.State != buffers.Available || tg.Slots[old.ID].PendingWait == nil {
45+ t.Fatal("old-generation slot was modified")
46+ }
47+}
48+
49+func TestTargetBrokenIsSticky(t *testing.T) {
50+ tg := &Target{wantW: 1, wantH: 1}
51+ boom := errors.New("boom")
52+ first := tg.fail(boom)
53+ if _, err := tg.Draw(nil, 1, &Output{}); !errors.Is(err, boom) || err != first {
54+ t.Fatalf("Draw after failure: %v", err)
55+ }
56+}
A internal/presentation/session/slot.go +79 -0
@@ -0,0 +1,79 @@
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+// beginReady does not change Pool ownership until the previous GPU submission
30+// has completed. An unready available buffer is retried on a bounded wakeup.
31+func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {
32+ waiting := false
33+ for _, b := range pool.Buffers {
34+ if b.Generation != pool.Generation || b.State != buffers.Available {
35+ continue
36+ }
37+ slot := slots[b.ID]
38+ if slot == nil {
39+ return nil, nil, false, fmt.Errorf("missing slot %d", b.ID)
40+ }
41+ ok, err := ready(slot)
42+ if err != nil {
43+ return nil, nil, false, err
44+ }
45+ if !ok {
46+ waiting = true
47+ continue
48+ }
49+ b.State = buffers.Rendering
50+ b.WlReleased, b.PointSignaled = false, false
51+ return b, slot, false, nil
52+ }
53+ return nil, nil, waiting, nil
54+}
55+
56+// installReleaseWait holds the new semaphore until the old submission is done.
57+// Only the state owner calls it. On an unready fence it leaves Pool Released.
58+func installReleaseWait(slot *Slot, next *vkdevice.BinaryFence, ready func() (bool, error)) (bool, error) {
59+ if slot.Wait != nil && slot.WaitInFlight {
60+ ok, err := ready()
61+ if err != nil {
62+ return false, err
63+ }
64+ if !ok {
65+ slot.PendingWait = next
66+ return false, nil
67+ }
68+ }
69+ if slot.Wait != nil {
70+ slot.Wait.Close()
71+ }
72+ slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false
73+ return true, nil
74+}
75+
76+type retiredPipelines struct {
77+ generation uint64
78+ list *vkdevice.Pipeline
79+}
A internal/presentation/session/target.go +603 -0
@@ -0,0 +1,603 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "image"
9+ "path/filepath"
10+
11+ "github.com/bnema/purego-vulkan/vulkan"
12+
13+ "github.com/bnema/nefergui/internal/layout"
14+ "github.com/bnema/nefergui/internal/presentation/buffers"
15+ "github.com/bnema/nefergui/internal/presentation/syncobj"
16+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
17+ "github.com/bnema/nefergui/internal/render"
18+ "golang.org/x/sys/unix"
19+)
20+
21+// Plane is one DMA-BUF plane. FD stays owned by the Target.
22+type Plane struct {
23+ FD int
24+ Offset uint32
25+ Stride uint32
26+}
27+
28+// Timeline is a DRM syncobj timeline the transport must import. FD stays owned
29+// by the Target and is valid only when the Output says the timeline is new.
30+type Timeline struct {
31+ ID uint64
32+ FD int
33+}
34+
35+// Retired names a buffer the transport must stop watching and destroy before
36+// the next Draw. ReleaseFD closes at that Draw.
37+type Retired struct {
38+ Buffer uint64
39+ ReleaseFD int
40+}
41+
42+// Output describes one presented frame in physical pixels. Slices are reused;
43+// they are valid until the next Draw.
44+type Output struct {
45+ Buffer uint64
46+ NewBuffer bool // import Planes as a new wl_buffer
47+ Retired []Retired
48+ Width int32
49+ Height int32
50+ FourCC uint32
51+ Modifier uint64
52+ Planes [4]Plane
53+ PlaneCount int
54+ Acquire Timeline // ID 0; FD always valid (persistent)
55+ Release Timeline // ID is the buffer; FD valid only with NewTimelines
56+ NewTimelines bool
57+ AcquirePoint uint64
58+ ReleasePoint uint64
59+ ReleaseFD int // eventfd readable when ReleasePoint signals; stable per buffer
60+}
61+
62+// TargetConfig selects the GPU and the compositor-acceptable formats.
63+type TargetConfig struct {
64+ MainDevice uint64
65+ Formats []vkdevice.Format
66+ Transparent bool
67+}
68+
69+// Target is the Wayland-free presentation core: it renders prepared frames
70+// into DMA-BUF images, tracks buffer ownership with explicit-sync timelines
71+// and reports release through per-buffer eventfds. It starts no goroutines and
72+// is used from one owner goroutine.
73+type Target struct {
74+ Device *vkdevice.Device
75+ Node *syncobj.Node
76+ Pool *buffers.Pool
77+ Preparer *render.Preparer
78+ Slots map[uint64]*Slot
79+
80+ list render.ListBuffer
81+ pipeline *vkdevice.Pipeline
82+ pipelineGen uint64 // pool generation the current pipeline belongs to
83+ retiredPipelines []retiredPipelines
84+ genPending bool // Pool.Resize done, images of the new generation not yet built
85+ waiting bool // the last Draw found work that only GPU progress unblocks
86+ broken error // sticky: a failure after Submit left the pipeline inconsistent
87+ acquire uint32
88+ acquireFD int
89+ formats []vkdevice.Format
90+ transparent bool
91+ selected vkdevice.Modifier
92+ wantW, wantH int32
93+ width, height int32
94+ pending []Retired // retired since the last Draw output
95+ reported []Retired // given to the caller; fds close at the next Draw
96+ capture bool
97+ closed bool
98+}
99+
100+// NewTarget opens the GPU and the render node. It allocates no images.
101+func NewTarget(cfg TargetConfig) (_ *Target, err error) {
102+ t := &Target{Slots: make(map[uint64]*Slot), acquireFD: -1, formats: append([]vkdevice.Format(nil), cfg.Formats...), transparent: cfg.Transparent}
103+ defer func() {
104+ if err != nil {
105+ err = errors.Join(err, t.Close())
106+ }
107+ }()
108+ if t.Preparer, err = render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages); err != nil {
109+ return nil, err
110+ }
111+ if t.Device, err = vkdevice.Open(vkdevice.DeviceIdentity(cfg.MainDevice)); err != nil {
112+ return nil, err
113+ }
114+ if t.Node, err = syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", t.Device.Render.Minor))); err != nil {
115+ return nil, err
116+ }
117+ if t.acquire, err = t.Node.Create(); err != nil {
118+ return nil, err
119+ }
120+ if t.acquireFD, err = t.Node.ExportTimeline(t.acquire); err != nil {
121+ return nil, err
122+ }
123+ if t.Pool, err = buffers.New(3); err != nil {
124+ return nil, err
125+ }
126+ // The pool starts with generation 1 buffers but no images yet; the first
127+ // Draw allocates them for the requested size.
128+ return t, nil
129+}
130+
131+// EnableReadback makes later images capture their output for Readback. Tests only.
132+func (t *Target) EnableReadback() { t.capture = true }
133+
134+// Resize sets the physical surface size; images follow lazily at the next Draw.
135+func (t *Target) Resize(width, height int32) { t.wantW, t.wantH = width, height }
136+
137+// Size reports the physical size of the current image generation.
138+func (t *Target) Size() (int32, int32) { return t.width, t.height }
139+
140+// newGeneration builds the pipeline and every image of the current pool
141+// generation into locals and commits them only when all succeeded, so a
142+// failure leaves the previous size, pipeline and slots intact.
143+func (t *Target) newGeneration() (err error) {
144+ mods, err := t.Device.ExportableModifiers()
145+ if err != nil {
146+ return err
147+ }
148+ selected, err := vkdevice.ChooseModifier(t.formats, t.transparent, mods)
149+ if err != nil {
150+ return err
151+ }
152+ pipeline, err := t.Device.NewListPipeline(t.wantW, t.wantH)
153+ if err != nil {
154+ return err
155+ }
156+ fresh := make(map[uint64]*Slot)
157+ defer func() {
158+ if err != nil {
159+ for _, slot := range fresh {
160+ _ = t.closeSlot(slot)
161+ }
162+ pipeline.Close()
163+ }
164+ }()
165+ for _, b := range t.Pool.Buffers {
166+ if b.Generation != t.Pool.Generation {
167+ continue
168+ }
169+ slot, err := t.newSlot(b, t.wantW, t.wantH, selected)
170+ if err != nil {
171+ return err
172+ }
173+ fresh[b.ID] = slot
174+ }
175+ if t.pipeline != nil {
176+ t.retiredPipelines = append(t.retiredPipelines, retiredPipelines{generation: t.pipelineGen, list: t.pipeline})
177+ }
178+ t.pipeline, t.pipelineGen, t.selected = pipeline, t.Pool.Generation, selected
179+ t.width, t.height = t.wantW, t.wantH
180+ for id, slot := range fresh {
181+ t.Slots[id] = slot
182+ }
183+ return nil
184+}
185+
186+func (t *Target) newSlot(b *buffers.Buffer, width, height int32, selected vkdevice.Modifier) (_ *Slot, err error) {
187+ slot := &Slot{Buffer: b, First: true, ReleaseFD: -1, ReleaseExport: -1}
188+ defer func() {
189+ if err != nil {
190+ _ = t.closeSlot(slot)
191+ }
192+ }()
193+ if slot.Image, err = t.Device.AllocateImage(width, height, selected); err != nil {
194+ return nil, err
195+ }
196+ if slot.Frame, err = t.Device.NewFrame(slot.Image); err != nil {
197+ return nil, err
198+ }
199+ if t.capture {
200+ if slot.Frame.Readback, err = t.Device.NewReadback(width, height); err != nil {
201+ return nil, err
202+ }
203+ }
204+ if slot.ReleaseHandle, err = t.Node.Create(); err != nil {
205+ return nil, err
206+ }
207+ if slot.ReleaseExport, err = t.Node.ExportTimeline(slot.ReleaseHandle); err != nil {
208+ return nil, err
209+ }
210+ if slot.ReleaseFD, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
211+ return nil, err
212+ }
213+ return slot, nil
214+}
215+
216+// closeSlot frees a slot whose GPU work has completed (or never started).
217+func (t *Target) closeSlot(slot *Slot) error {
218+ if slot.Frame != nil {
219+ slot.Frame.Close()
220+ }
221+ if slot.PendingWait != nil {
222+ slot.PendingWait.Close()
223+ }
224+ if slot.Wait != nil {
225+ slot.Wait.Close()
226+ }
227+ if slot.Image != nil {
228+ slot.Image.Close()
229+ }
230+ var err error
231+ if slot.ReleaseHandle != 0 {
232+ err = t.Node.Destroy(slot.ReleaseHandle)
233+ }
234+ closeFD(&slot.ReleaseExport)
235+ return err
236+}
237+
238+func closeFD(fd *int) {
239+ if *fd >= 0 {
240+ _ = unix.Close(*fd)
241+ *fd = -1
242+ }
243+}
244+
245+// Draw prepares and presents one frame. It returns false, touching no GPU
246+// state, when no image is ready; the caller retries after Released, or after a
247+// short delay while Waiting reports GPU progress is needed.
248+func (t *Target) Draw(display []layout.Command, scale float64, out *Output) (bool, error) {
249+ if t.broken != nil {
250+ return false, t.broken
251+ }
252+ if t.closed {
253+ return false, errors.New("session: target closed")
254+ }
255+ if t.wantW <= 0 || t.wantH <= 0 {
256+ return false, nil
257+ }
258+ for _, r := range t.reported {
259+ _ = unix.Close(r.ReleaseFD)
260+ }
261+ t.reported = t.reported[:0]
262+ if t.genPending || t.width != t.wantW || t.height != t.wantH {
263+ if !t.genPending && t.width != 0 {
264+ if err := t.Pool.Resize(3); err != nil {
265+ return false, err
266+ }
267+ }
268+ t.genPending = true
269+ if err := t.newGeneration(); err != nil {
270+ return false, err
271+ }
272+ t.genPending = false
273+ }
274+ if err := t.retire(); err != nil {
275+ return false, err
276+ }
277+ pending, err := t.finishPending()
278+ if err != nil {
279+ return false, err
280+ }
281+ b, slot, waiting, err := beginReady(t.Pool, t.Slots, func(s *Slot) (bool, error) { return s.Frame.Ready() })
282+ if err != nil {
283+ return false, err
284+ }
285+ t.waiting = pending || waiting
286+ if b == nil {
287+ return false, nil
288+ }
289+ ok, err := t.submit(b, slot, display, scale, out)
290+ if err != nil {
291+ // Before Submit nothing reached the GPU and the buffer is reusable.
292+ if t.broken == nil && b.State == buffers.Rendering {
293+ b.State = buffers.Available
294+ }
295+ return false, err
296+ }
297+ t.waiting = false
298+ return ok, nil
299+}
300+
301+// Waiting reports that the last Draw found a frame blocked only on GPU
302+// progress (an image still being drawn, or a release wait not yet installed),
303+// which no event announces: the caller should retry after a short delay. It is
304+// false when every image is owned by the compositor; Released unblocks that.
305+func (t *Target) Waiting() bool { return t.waiting }
306+
307+func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, scale float64, out *Output) (bool, error) {
308+ frame, err := t.Preparer.Prepare(display, scale, int(t.width), int(t.height))
309+ if err != nil {
310+ return false, err
311+ }
312+ defer t.list.Release()
313+ instances, batches, stats := t.list.List(frame)
314+ if len(stats.Skipped) != 0 {
315+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
316+ }
317+ if slot.Wait != nil && slot.WaitInFlight {
318+ slot.Wait.Close()
319+ slot.Wait = nil
320+ slot.WaitInFlight = false
321+ }
322+ if err = slot.Frame.RecordList(slot.Image, t.pipeline, t.width, t.height, instances, frame.Uploads, batches, slot.First, t.capture); err != nil {
323+ return false, err
324+ }
325+ if err = slot.Frame.Submit(slot.Wait); err != nil {
326+ return false, err
327+ }
328+ t.Preparer.Submitted(frame.Uploads)
329+ if slot.Wait != nil {
330+ slot.WaitInFlight = true
331+ }
332+ slot.First = false
333+ // From here the buffer is committed to the pipeline; failures are fatal.
334+ fd, err := slot.Frame.Signal.Export()
335+ if err != nil {
336+ return false, t.fail(err)
337+ }
338+ acquire, err := t.Pool.Acquire(b)
339+ if err != nil {
340+ _ = unix.Close(fd)
341+ return false, t.fail(err)
342+ }
343+ if err = t.Node.AcquirePoint(fd, t.acquire, acquire); err != nil {
344+ _ = unix.Close(fd)
345+ return false, t.fail(err)
346+ }
347+ release, err := t.Pool.Commit(b)
348+ if err != nil {
349+ return false, t.fail(err)
350+ }
351+ if err = t.Node.EventFD(slot.ReleaseHandle, release, slot.ReleaseFD); err != nil {
352+ return false, t.fail(err)
353+ }
354+ if err = t.Pool.Own(b); err != nil {
355+ return false, t.fail(err)
356+ }
357+ t.fill(out, slot, acquire, release)
358+ return true, nil
359+}
360+
361+// fail records an error after Submit: the buffer cannot go back to Available
362+// and every later Draw returns this error.
363+func (t *Target) fail(err error) error {
364+ t.broken = fmt.Errorf("session: presentation pipeline broken: %w", err)
365+ return t.broken
366+}
367+
368+func (t *Target) fill(out *Output, slot *Slot, acquire, release uint64) {
369+ b := slot.Buffer
370+ retired := append(out.Retired[:0], t.pending...)
371+ t.reported = append(t.reported[:0], t.pending...)
372+ t.pending = t.pending[:0]
373+ *out = Output{
374+ Buffer: b.ID, NewBuffer: !slot.Presented, Retired: retired,
375+ Width: t.width, Height: t.height, FourCC: t.selected.DRMFormat, Modifier: slot.Image.Modifier,
376+ PlaneCount: 1, Acquire: Timeline{ID: 0, FD: t.acquireFD}, Release: Timeline{ID: b.ID, FD: -1},
377+ NewTimelines: !slot.Presented, AcquirePoint: acquire, ReleasePoint: release, ReleaseFD: slot.ReleaseFD,
378+ }
379+ out.Planes[0] = Plane{FD: slot.Image.FD, Offset: slot.Image.Offset, Stride: slot.Image.Stride}
380+ if !slot.Presented {
381+ out.Release.FD = slot.ReleaseExport
382+ slot.Presented = true
383+ }
384+}
385+
386+// Released handles a readable release eventfd of the buffer: the compositor
387+// signaled its latest release point, so the image may be reused once the GPU
388+// wait is installed.
389+func (t *Target) Released(id uint64) error {
390+ slot := t.Slots[id]
391+ if slot == nil {
392+ return nil // retired: its descriptors are gone
393+ }
394+ // Drain first: a notification that lands after the drain is seen by the
395+ // Signaled check below or leaves the eventfd readable for a later no-op call.
396+ if err := drainEventFD(slot.ReleaseFD); err != nil {
397+ return err
398+ }
399+ b := slot.Buffer
400+ if b.State != buffers.CompositorOwned {
401+ return nil
402+ }
403+ signaled, err := t.Node.Signaled(slot.ReleaseHandle, b.ReleasePoint)
404+ if err != nil {
405+ return err
406+ }
407+ if !signaled {
408+ return nil // stale notification; the registration for the live point is still armed
409+ }
410+ return t.release(slot, b.ReleasePoint)
411+}
412+
413+// drainEventFD resets a non-blocking eventfd; an empty one is fine.
414+func drainEventFD(fd int) error {
415+ var counter [8]byte
416+ if _, err := unix.Read(fd, counter[:]); err != nil && err != unix.EAGAIN {
417+ return fmt.Errorf("session: read release eventfd: %w", err)
418+ }
419+ return nil
420+}
421+
422+func (t *Target) release(slot *Slot, point uint64) error {
423+ b := slot.Buffer
424+ // An older completion cannot authorize a newer commit of the same image.
425+ if b.ReleasePoint != point || b.State != buffers.CompositorOwned {
426+ return nil
427+ }
428+ if err := t.Pool.Signal(b, point); err != nil {
429+ return err
430+ }
431+ if b.Generation == t.Pool.Generation {
432+ fd, err := t.Node.ReleaseFence(slot.ReleaseHandle, point)
433+ if err != nil {
434+ return err
435+ }
436+ wait, err := t.Device.NewBinaryFence(false)
437+ if err != nil {
438+ _ = unix.Close(fd)
439+ return err
440+ }
441+ if err = wait.ImportTemporary(fd); err != nil {
442+ _ = unix.Close(fd)
443+ wait.Close()
444+ return err
445+ }
446+ installed, err := installReleaseWait(slot, wait, slot.Frame.Ready)
447+ if err != nil {
448+ wait.Close()
449+ return err
450+ }
451+ if !installed {
452+ return nil
453+ }
454+ if err = t.Pool.Reuse(b, true); err != nil {
455+ return err
456+ }
457+ }
458+ return t.retire()
459+}
460+
461+// finishPending installs release waits deferred because the old submission was
462+// still running. It never waits on the GPU.
463+func (t *Target) finishPending() (bool, error) {
464+ pending := false
465+ for _, slot := range t.Slots {
466+ // Old generations never return to Available; retire frees them.
467+ if slot.PendingWait == nil || slot.Buffer.Generation != t.Pool.Generation {
468+ continue
469+ }
470+ ready, err := slot.Frame.Ready()
471+ if err != nil {
472+ return false, err
473+ }
474+ if !ready {
475+ pending = true
476+ continue
477+ }
478+ if slot.Wait != nil {
479+ slot.Wait.Close()
480+ }
481+ slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
482+ if err := t.Pool.Reuse(slot.Buffer, true); err != nil {
483+ return false, err
484+ }
485+ }
486+ return pending, nil
487+}
488+
489+// retire frees images of old generations once nothing uses them, and queues
490+// the ones the transport imported for the next Output.
491+func (t *Target) retire() error {
492+ for id, slot := range t.Slots {
493+ b := slot.Buffer
494+ if !t.Pool.Retirable(b) {
495+ continue
496+ }
497+ ready, err := slot.Frame.Ready()
498+ if err != nil {
499+ return err
500+ }
501+ if !ready {
502+ continue
503+ }
504+ eventFD := slot.ReleaseFD
505+ slot.ReleaseFD = -1
506+ wasPresented := slot.Presented
507+ err = t.closeSlot(slot)
508+ if wasPresented {
509+ t.pending = append(t.pending, Retired{Buffer: id, ReleaseFD: eventFD})
510+ } else if eventFD >= 0 {
511+ _ = unix.Close(eventFD)
512+ }
513+ delete(t.Slots, id)
514+ if rerr := t.Pool.Remove(b); rerr != nil {
515+ return errors.Join(err, rerr)
516+ }
517+ if err != nil {
518+ return err
519+ }
520+ }
521+ for i := 0; i < len(t.retiredPipelines); {
522+ old := t.retiredPipelines[i]
523+ live := false
524+ for _, slot := range t.Slots {
525+ if slot.Buffer.Generation == old.generation {
526+ live = true
527+ break
528+ }
529+ }
530+ if live {
531+ i++
532+ continue
533+ }
534+ old.list.Close()
535+ t.retiredPipelines = append(t.retiredPipelines[:i], t.retiredPipelines[i+1:]...)
536+ }
537+ return nil
538+}
539+
540+// Readback returns the last drawn pixels of a buffer in RGBA order. Tests only;
541+// call EnableReadback before the first Draw.
542+func (t *Target) Readback(id uint64) (*image.NRGBA, error) {
543+ slot := t.Slots[id]
544+ if slot == nil || slot.Frame.Readback == nil {
545+ return nil, errors.New("session: no readback for buffer")
546+ }
547+ if err := slot.Frame.Wait(); err != nil {
548+ return nil, err
549+ }
550+ img, err := slot.Frame.Readback.Copy()
551+ if err != nil {
552+ return nil, err
553+ }
554+ vkdevice.SwizzleBGRA(img)
555+ return img, nil
556+}
557+
558+// Close waits for the GPU, then frees every descriptor and image, always
559+// completing the teardown. The compositor's imports keep their own kernel
560+// references. It returns the joined errors of the steps that failed and any
561+// Vulkan validation errors recorded during the Target's life.
562+func (t *Target) Close() error {
563+ if t == nil || t.closed {
564+ return nil
565+ }
566+ t.closed = true
567+ var errs []error
568+ if t.Device != nil && t.Device.Logical != 0 && t.Device.Dispatch != nil {
569+ errs = append(errs, vulkan.Check(t.Device.Dispatch.DeviceWaitIdle(t.Device.Logical)))
570+ }
571+ for _, r := range t.reported {
572+ _ = unix.Close(r.ReleaseFD)
573+ }
574+ for _, r := range t.pending {
575+ _ = unix.Close(r.ReleaseFD)
576+ }
577+ t.reported, t.pending = nil, nil
578+ for id, slot := range t.Slots {
579+ closeFD(&slot.ReleaseFD)
580+ errs = append(errs, t.closeSlot(slot))
581+ delete(t.Slots, id)
582+ }
583+ for _, old := range t.retiredPipelines {
584+ old.list.Close()
585+ }
586+ t.retiredPipelines = nil
587+ t.pipeline.Close()
588+ closeFD(&t.acquireFD)
589+ if t.Node != nil {
590+ if t.acquire != 0 {
591+ errs = append(errs, t.Node.Destroy(t.acquire))
592+ }
593+ errs = append(errs, t.Node.Close())
594+ }
595+ if t.Device != nil {
596+ // Read the messages before Close destroys the messenger.
597+ if msgs := t.Device.Validation.Messages(); len(msgs) > 0 {
598+ errs = append(errs, fmt.Errorf("%d Vulkan validation errors: %v", len(msgs), msgs))
599+ }
600+ }
601+ t.Device.Close()
602+ return errors.Join(errs...)
603+}
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-}
M internal/presentation/shaders/freshness_test.go +2 -2
@@ -12,7 +12,7 @@ func TestEmbeddedSPIRV(t *testing.T) {
1212 for _, tc := range []struct {
1313 name string
1414 data []byte
15- }{{"vertex", Vertex}, {"fragment", Fragment}, {"list vertex", ListVertex}, {"list fragment", ListFragment}} {
15+ }{{"list vertex", ListVertex}, {"list fragment", ListFragment}} {
1616 if len(tc.data) < 20 || !bytes.Equal(tc.data[:4], []byte{3, 2, 35, 7}) || len(tc.data)%4 != 0 {
1717 t.Fatalf("%s: not SPIR-V", tc.name)
1818 }
@@ -27,7 +27,7 @@ func TestShaderFreshness(t *testing.T) {
2727 for _, tc := range []struct {
2828 file, stage string
2929 embedded []byte
30- }{{"rect.vert", "vertex", Vertex}, {"rect.frag", "fragment", Fragment}, {"list.vert", "vertex", ListVertex}, {"list.frag", "fragment", ListFragment}} {
30+ }{{"list.vert", "vertex", ListVertex}, {"list.frag", "fragment", ListFragment}} {
3131 out := filepath.Join(t.TempDir(), "shader.spv")
3232 cmd := exec.Command(bin, "-O", "-fshader-stage="+tc.stage, "-o", out, tc.file)
3333 if log, err := cmd.CombinedOutput(); err != nil {
D internal/presentation/shaders/rect.frag +0 -9
@@ -1,9 +0,0 @@
1-#version 450
2-layout(push_constant) uniform Rect {
3- vec4 bounds;
4- vec4 color;
5-} pc;
6-layout(location = 0) out vec4 outColor;
7-void main() {
8- outColor = vec4(pc.color.rgb * pc.color.a, pc.color.a);
9-}
D internal/presentation/shaders/rect.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

D internal/presentation/shaders/rect.vert +0 -12
@@ -1,12 +0,0 @@
1-#version 450
2-layout(push_constant) uniform Rect {
3- vec4 bounds; // normalized x,y,width,height in framebuffer coordinates
4- vec4 color; // straight input alpha, premultiplied by the fragment stage
5-} pc;
6-void main() {
7- vec2 pos[6] = vec2[6](
8- vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
9- vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
10- vec2 xy = pc.bounds.xy + pos[gl_VertexIndex] * pc.bounds.zw;
11- gl_Position = vec4(xy.x * 2.0 - 1.0, 1.0 - xy.y * 2.0, 0.0, 1.0);
12-}
D internal/presentation/shaders/rect.vert.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/presentation/shaders/shaders.go +0 -8
@@ -4,17 +4,9 @@ package shaders
44
55 import _ "embed"
66
7-//go:generate glslc -O -fshader-stage=vertex -o rect.vert.spv rect.vert
8-//go:generate glslc -O -fshader-stage=fragment -o rect.frag.spv rect.frag
97 //go:generate glslc -O -fshader-stage=vertex -o list.vert.spv list.vert
108 //go:generate glslc -O -fshader-stage=fragment -o list.frag.spv list.frag
119
12-//go:embed rect.vert.spv
13-var Vertex []byte
14-
15-//go:embed rect.frag.spv
16-var Fragment []byte
17-
1810 //go:embed list.vert.spv
1911 var ListVertex []byte
2012
M internal/presentation/syncobj/syncobj.go +38 -4
@@ -41,6 +41,15 @@ type queryArg struct {
4141 Count, Flags uint32
4242 }
4343
44+// eventFDArg is struct drm_syncobj_eventfd: the kernel signals fd once the
45+// timeline point is signaled (flags 0) without any thread waiting on it.
46+type eventFDArg struct {
47+ Handle, Flags uint32
48+ Point uint64
49+ FD int32
50+ Pad uint32
51+}
52+
4453 const (
4554 ioctlCreate = uintptr(0xc00864bf)
4655 ioctlDestroy = uintptr(0xc00864c0)
@@ -49,6 +58,8 @@ const (
4958 ioctlTimelineWait = uintptr(0xc03064ca)
5059 ioctlQuery = uintptr(0xc01864cb)
5160 ioctlTransfer = uintptr(0xc02064cc)
61+ ioctlTimelineSig = uintptr(0xc01864cd)
62+ ioctlEventFD = uintptr(0xc01864cf)
5263 importSyncFile = 1
5364 exportSyncFile = 1
5465 )
@@ -107,9 +118,8 @@ func (n *Node) Destroy(handle uint32) error {
107118 return n.ioctl(ioctlDestroy, unsafe.Pointer(&destroyArg{Handle: handle}))
108119 }
109120
110-// ExportTimeline returns a new owned syncobj FD (not a sync_file). The caller
111-// hands it to WLTurbo's ImportTimeline, which closes only after a successful
112-// request send; on send failure the caller must close the FD.
121+// ExportTimeline returns a new owned syncobj FD (not a sync_file). The
122+// caller owns it and closes it, or passes a duplicate to its Wayland client.
113123 func (n *Node) ExportTimeline(handle uint32) (int, error) {
114124 a := handleArg{Handle: handle, FD: -1}
115125 if err := n.ioctl(ioctlHandleToFD, unsafe.Pointer(&a)); err != nil {
@@ -118,6 +128,30 @@ func (n *Node) ExportTimeline(handle uint32) (int, error) {
118128 return int(a.FD), nil
119129 }
120130
131+// EventFD arms efd, an eventfd owned by the caller, to become readable when
132+// point of the timeline behind handle is signaled. The point need not be
133+// submitted yet. The registration is one-shot: re-arm after every use. The
134+// call returns at once and never waits.
135+func (n *Node) EventFD(handle uint32, point uint64, efd int) error {
136+ a := eventFDArg{Handle: handle, Point: point, FD: int32(efd)}
137+ return n.ioctl(ioctlEventFD, unsafe.Pointer(&a))
138+}
139+
140+// Signal signals one timeline point from the CPU (DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL).
141+// The compositor does this through its release timeline; clients use it only
142+// to simulate that in tests.
143+func (n *Node) Signal(handle uint32, point uint64) error {
144+ h, p := handle, point
145+ a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), Count: 1}
146+ errno := n.ioctlErrno(ioctlTimelineSig, unsafe.Pointer(&a))
147+ runtime.KeepAlive(h)
148+ runtime.KeepAlive(p)
149+ if errno != 0 {
150+ return ioctlError(ioctlTimelineSig, errno)
151+ }
152+ return nil
153+}
154+
121155 // ImportFence consumes syncFile only after FD_TO_HANDLE imports it into a
122156 // pre-created destination syncobj. With IMPORT_SYNC_FILE, Handle is an input,
123157 // not an output: asking the kernel to import into handle zero returns ENOENT.
@@ -175,7 +209,7 @@ func (n *Node) ReleaseFence(timeline uint32, point uint64) (int, error) {
175209 }
176210
177211 // WaitPoint waits for a submitted release point for at most timeout. Call it
178-// only from a waiter goroutine, never from the presentation loop. WAIT_FOR_SUBMIT
212+// only off the render path: it can block for up to timeout. WAIT_FOR_SUBMIT
179213 // handles the interval before the compositor attaches its fence to the point.
180214 func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bool, error) {
181215 if timeout < 0 || timeout > time.Second {
M internal/presentation/syncobj/syncobj_test.go +78 -0
@@ -22,6 +22,7 @@ func TestUAPILayouts(t *testing.T) {
2222 {"transfer", unsafe.Sizeof(transferArg{}), 32},
2323 {"wait", unsafe.Sizeof(timelineWaitArg{}), 48},
2424 {"query", unsafe.Sizeof(queryArg{}), 24},
25+ {"eventfd", unsafe.Sizeof(eventFDArg{}), 24},
2526 } {
2627 if tc.got != tc.want {
2728 t.Errorf("%s size: got %d want %d", tc.name, tc.got, tc.want)
@@ -102,3 +103,80 @@ func TestFailedImportRetainsFD(t *testing.T) {
102103 t.Fatalf("lost caller FD after failed import: %v", err)
103104 }
104105 }
106+
107+// DRM_IOWR(type 'd', nr, size): direction 3<<30, size<<16, type<<8, nr.
108+func TestEventFDRequestNumber(t *testing.T) {
109+ want := uintptr(3<<30 | unsafe.Sizeof(eventFDArg{})<<16 | 'd'<<8 | 0xCF)
110+ if ioctlEventFD != want {
111+ t.Fatalf("DRM_IOCTL_SYNCOBJ_EVENTFD: got %#x want %#x", ioctlEventFD, want)
112+ }
113+ want = uintptr(3<<30 | unsafe.Sizeof(queryArg{})<<16 | 'd'<<8 | 0xCD)
114+ if ioctlTimelineSig != want {
115+ t.Fatalf("DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL: got %#x want %#x", ioctlTimelineSig, want)
116+ }
117+}
118+
119+func TestEventFDBadFDFailsWithErrno(t *testing.T) {
120+ n := &Node{fd: -1}
121+ if err := n.EventFD(1, 1, 0); !errors.Is(err, unix.EBADF) {
122+ t.Fatalf("want EBADF, got %v", err)
123+ }
124+}
125+
126+// A point signaled from the CPU makes the registered eventfd readable without
127+// any waiter thread; the registration is one-shot.
128+func TestEventFDSignaledPointHardware(t *testing.T) {
129+ path := os.Getenv("NEFERGUI_RENDER_NODE")
130+ if path == "" {
131+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
132+ }
133+ n, err := Open(path)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ defer n.Close()
138+ handle, err := n.Create()
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ defer n.Destroy(handle)
143+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ defer unix.Close(efd)
148+ if err = n.EventFD(handle, 7, efd); err != nil {
149+ t.Fatalf("EventFD: %v", err)
150+ }
151+ readable := func(timeout int) bool {
152+ fds := []unix.PollFd{{Fd: int32(efd), Events: unix.POLLIN}}
153+ r, err := unix.Poll(fds, timeout)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ return r > 0
158+ }
159+ if readable(0) {
160+ t.Fatal("eventfd readable before the point signaled")
161+ }
162+ if err = n.Signal(handle, 7); err != nil {
163+ t.Fatalf("Signal: %v", err)
164+ }
165+ if !readable(1000) {
166+ t.Fatal("eventfd not readable after the point signaled")
167+ }
168+ var buf [8]byte
169+ if _, err = unix.Read(efd, buf[:]); err != nil {
170+ t.Fatal(err)
171+ }
172+ if readable(0) {
173+ t.Fatal("eventfd still readable after read")
174+ }
175+ // Re-arming for an already signaled point fires immediately.
176+ if err = n.EventFD(handle, 7, efd); err != nil {
177+ t.Fatal(err)
178+ }
179+ if !readable(1000) {
180+ t.Fatal("re-armed eventfd for a signaled point stayed silent")
181+ }
182+}
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-}
M internal/presentation/vkdevice/image.go +14 -34
@@ -8,28 +8,28 @@ import (
88 "unsafe"
99
1010 "github.com/bnema/purego-vulkan/vulkan"
11- "github.com/bnema/wlturbo/protocol/core"
12- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
1311 "golang.org/x/sys/unix"
1412 )
1513
16-// Image owns a Vulkan image, dedicated exportable memory and its Wayland buffer.
17-// Close must only be called after both wl_buffer.release and the compositor's
18-// release timeline point, or for an image never attached to a surface.
14+// Image owns a Vulkan image, dedicated exportable memory and the exported
15+// DMA-BUF file descriptor. FD stays owned by the Image and is closed by Close;
16+// a transport that consumes descriptors must be handed a dup. The compositor's
17+// import keeps its own kernel reference, so Close may run while the compositor
18+// still holds the buffer, but the GPU must be done with the image first.
1919 type Image struct {
2020 Image vulkan.Image
2121 Memory vulkan.DeviceMemory
22- Buffer *core.Buffer
22+ FD int // exported DMA-BUF, -1 when none
2323 Modifier uint64
2424 Offset, Stride uint32
2525 device *Device
2626 }
2727
28-func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *linuxdmabuf.LinuxDmabuf) (img *Image, err error) {
29- if width <= 0 || height <= 0 || dmabuf == nil {
30- return nil, fmt.Errorf("invalid DMA-BUF image dimensions or manager")
28+func (d *Device) AllocateImage(width, height int32, selected Modifier) (img *Image, err error) {
29+ if width <= 0 || height <= 0 {
30+ return nil, fmt.Errorf("invalid DMA-BUF image dimensions")
3131 }
32- img = &Image{device: d}
32+ img = &Image{device: d, FD: -1}
3333 defer func() {
3434 if err != nil {
3535 img.Close()
@@ -88,27 +88,7 @@ func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *l
8888 if err = vulkan.Check(d.Dispatch.GetMemoryFdKHR(d.Logical, &fdInfo, &fd)); err != nil {
8989 return nil, fmt.Errorf("export DMA-BUF: %w", err)
9090 }
91- // WLTurbo consumes fd only when its complete Add request succeeds. On a
92- // failed send Vulkan still owns the image and we still own the exported FD.
93- owned := true
94- defer func() {
95- if owned {
96- _ = unix.Close(int(fd))
97- }
98- }()
99- params, e := dmabuf.CreateParams()
100- if e != nil {
101- return nil, e
102- }
103- defer params.Destroy()
104- if err = params.Add(int(fd), 0, img.Offset, img.Stride, uint32(img.Modifier>>32), uint32(img.Modifier)); err != nil {
105- return nil, err
106- }
107- owned = false
108- img.Buffer, err = params.CreateImmed(width, height, selected.DRMFormat, 0)
109- if err != nil {
110- return nil, err
111- }
91+ img.FD = int(fd)
11292 return img, nil
11393 }
11494
@@ -116,9 +96,9 @@ func (img *Image) Close() {
11696 if img == nil || img.device == nil {
11797 return
11898 }
119- if img.Buffer != nil {
120- _ = img.Buffer.Destroy()
121- img.Buffer = nil
99+ if img.FD >= 0 {
100+ _ = unix.Close(img.FD)
101+ img.FD = -1
122102 }
123103 if img.Image != 0 {
124104 img.device.Dispatch.DestroyImage(img.device.Logical, img.Image, nil)
M internal/presentation/vkdevice/modifier.go +12 -7
@@ -7,7 +7,6 @@ import (
77 "unsafe"
88
99 "github.com/bnema/purego-vulkan/vulkan"
10- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
1110 )
1211
1312 // Modifier is the one-plane GPU/compositor common image layout chosen in
@@ -22,16 +21,22 @@ const (
2221 ARGB8888 uint32 = 0x34325241
2322 )
2423
25-func ChooseModifier(tranches [][]linuxdmabuf.FormatEntry, transparent bool, supported map[uint64]bool) (Modifier, error) {
24+// Format is one DRM format and modifier pair the compositor accepts.
25+type Format struct {
26+ FourCC uint32
27+ Modifier uint64
28+}
29+
30+// ChooseModifier picks the first format in the compositor's preference order
31+// whose modifier the GPU can also export.
32+func ChooseModifier(formats []Format, transparent bool, supported map[uint64]bool) (Modifier, error) {
2633 format := XRGB8888
2734 if transparent {
2835 format = ARGB8888
2936 }
30- for _, tranche := range tranches {
31- for _, entry := range tranche {
32- if entry.Format == format && supported[entry.Modifier] {
33- return Modifier{format, entry.Modifier}, nil
34- }
37+ for _, f := range formats {
38+ if f.FourCC == format && supported[f.Modifier] {
39+ return Modifier{format, f.Modifier}, nil
3540 }
3641 }
3742 return Modifier{}, fmt.Errorf("no one-plane exportable B8G8R8A8 modifier shared with compositor for DRM format %#x", format)
M internal/presentation/vkdevice/modifier_test.go +1 -2
@@ -3,14 +3,13 @@
33 package vkdevice
44
55 import (
6- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
76 "golang.org/x/sys/unix"
87 "os"
98 "testing"
109 )
1110
1211 func TestModifierIntersection(t *testing.T) {
13- tranches := [][]linuxdmabuf.FormatEntry{{{Format: XRGB8888, Modifier: 11}, {Format: ARGB8888, Modifier: 22}}, {{Format: XRGB8888, Modifier: 33}, {Format: ARGB8888, Modifier: 44}}}
12+ tranches := []Format{{XRGB8888, 11}, {ARGB8888, 22}, {XRGB8888, 33}, {ARGB8888, 44}}
1413 for _, tc := range []struct {
1514 alpha bool
1615 available map[uint64]bool
M internal/presentation/vkdevice/pipeline.go +31 -61
@@ -12,13 +12,6 @@ import (
1212 "github.com/bnema/purego-vulkan/vulkan"
1313 )
1414
15-// Rect contains a normalized framebuffer rectangle and straight RGBA color.
16-// The fragment stage outputs premultiplied RGBA, including transparent alpha.
17-type Rect struct {
18- Bounds [4]float32
19- Color [4]float32
20-}
21-
2215 // Instance is a physical-pixel list quad. Color is straight sRGB; the shader
2316 // premultiplies it. Clip uses x/y/width/height, radii are TL/TR/BR/BL.
2417 // KindLayer.x = 6 samples set 1 binding 0 as a 2D image texture.
@@ -59,19 +52,11 @@ type Pipeline struct {
5952 Layout vulkan.PipelineLayout
6053 }
6154
62-// NewRectPipeline fixes the Vulkan viewport and scissor to the image size.
63-// The pinned Vulkan binding does not expose vkCmdSetViewport/Scissor, so resize
64-// builds a new pipeline rather than recording unsupported dynamic-state calls.
65-func (d *Device) NewRectPipeline(width, height int32) (p *Pipeline, err error) {
66- return d.newPipeline(width, height, false)
67-}
68-
69-// NewListPipeline creates an instanced, premultiplied-alpha quad pipeline.
55+// NewListPipeline creates an instanced, premultiplied-alpha quad pipeline. It
56+// fixes the Vulkan viewport and scissor to the image size: the pinned Vulkan
57+// binding does not expose vkCmdSetViewport/Scissor, so resize builds a new
58+// pipeline rather than recording unsupported dynamic-state calls.
7059 func (d *Device) NewListPipeline(width, height int32) (p *Pipeline, err error) {
71- return d.newPipeline(width, height, true)
72-}
73-
74-func (d *Device) newPipeline(width, height int32, list bool) (p *Pipeline, err error) {
7560 if width <= 0 || height <= 0 {
7661 return nil, fmt.Errorf("invalid render area %dx%d", width, height)
7762 }
@@ -81,44 +66,32 @@ func (d *Device) newPipeline(width, height int32, list bool) (p *Pipeline, err e
8166 p.Close()
8267 }
8368 }()
84- pushSize := uint32(unsafe.Sizeof(Rect{}))
85- if list {
86- pushSize = 8
87- if d.atlas == nil {
88- d.atlas, err = d.newGlyphAtlas()
89- if err != nil {
90- return nil, err
91- }
92- }
93- if d.images == nil {
94- d.images, err = d.newImageCache()
95- if err != nil {
96- return nil, err
97- }
69+ if d.atlas == nil {
70+ d.atlas, err = d.newGlyphAtlas()
71+ if err != nil {
72+ return nil, err
9873 }
9974 }
100- rangeInfo := vulkan.PushConstantRange{StageFlags: vulkan.ShaderStageVertexBit | vulkan.ShaderStageFragmentBit, Size: pushSize}
101- layoutInfo := vulkan.PipelineLayoutCreateInfo{SType: vulkan.StructureTypePipelineLayoutCreateInfo, PushConstantRangeCount: 1, PushConstantRanges: &rangeInfo}
102- if list {
103- // Set 0 samples the atlas; set 1 samples the image descriptor
104- // bound for the current image batch.
105- layouts := [2]vulkan.DescriptorSetLayout{d.atlas.Layout, d.images.Layout}
106- layoutInfo.SetLayoutCount = uint32(len(layouts))
107- layoutInfo.SetLayouts = &layouts[0]
75+ if d.images == nil {
76+ d.images, err = d.newImageCache()
77+ if err != nil {
78+ return nil, err
79+ }
10880 }
81+ rangeInfo := vulkan.PushConstantRange{StageFlags: vulkan.ShaderStageVertexBit | vulkan.ShaderStageFragmentBit, Size: 8}
82+ // Set 0 samples the atlas; set 1 samples the image descriptor bound for
83+ // the current image batch.
84+ layouts := [2]vulkan.DescriptorSetLayout{d.atlas.Layout, d.images.Layout}
85+ layoutInfo := vulkan.PipelineLayoutCreateInfo{SType: vulkan.StructureTypePipelineLayoutCreateInfo, SetLayoutCount: uint32(len(layouts)), SetLayouts: &layouts[0], PushConstantRangeCount: 1, PushConstantRanges: &rangeInfo}
10986 if err = vulkan.Check(d.Dispatch.CreatePipelineLayout(d.Logical, &layoutInfo, nil, &p.Layout)); err != nil {
110- return nil, fmt.Errorf("create rect pipeline layout: %w", err)
87+ return nil, fmt.Errorf("create list pipeline layout: %w", err)
11188 }
112- vert, frag := shaders.Vertex, shaders.Fragment
113- if list {
114- vert, frag = shaders.ListVertex, shaders.ListFragment
115- }
116- vertex, err := d.shaderModule(vert)
89+ vertex, err := d.shaderModule(shaders.ListVertex)
11790 if err != nil {
11891 return nil, err
11992 }
12093 defer d.Dispatch.DestroyShaderModule(d.Logical, vertex, nil)
121- fragment, err := d.shaderModule(frag)
94+ fragment, err := d.shaderModule(shaders.ListFragment)
12295 if err != nil {
12396 return nil, err
12497 }
@@ -129,19 +102,16 @@ func (d *Device) newPipeline(width, height int32, list bool) (p *Pipeline, err e
129102 {SType: vulkan.StructureTypePipelineShaderStageCreateInfo, Stage: vulkan.ShaderStageFragmentBit, Module: fragment, Name: &main[0]},
130103 }
131104 vertexInput := vulkan.PipelineVertexInputStateCreateInfo{SType: vulkan.StructureTypePipelineVertexInputStateCreateInfo}
132- var binding vulkan.VertexInputBindingDescription
133105 var attrs [13]vulkan.VertexInputAttributeDescription
134- if list {
135- binding = vulkan.VertexInputBindingDescription{Stride: uint32(unsafe.Sizeof(Instance{})), InputRate: vulkan.VertexInputRateInstance}
136- offsets := [13]uint32{uint32(unsafe.Offsetof(Instance{}.Bounds)), uint32(unsafe.Offsetof(Instance{}.Color)), uint32(unsafe.Offsetof(Instance{}.Clip)), uint32(unsafe.Offsetof(Instance{}.Radii)), uint32(unsafe.Offsetof(Instance{}.UV)), uint32(unsafe.Offsetof(Instance{}.KindLayer)), uint32(unsafe.Offsetof(Instance{}.Widths)), uint32(unsafe.Offsetof(Instance{}.Sides)), uint32(unsafe.Offsetof(Instance{}.Sides)) + 16, uint32(unsafe.Offsetof(Instance{}.Sides)) + 32, uint32(unsafe.Offsetof(Instance{}.Sides)) + 48, uint32(unsafe.Offsetof(Instance{}.Shape)), uint32(unsafe.Offsetof(Instance{}.Shadow))}
137- for i := range attrs {
138- attrs[i] = vulkan.VertexInputAttributeDescription{Location: uint32(i), Format: vulkan.FormatR32g32b32a32Sfloat, Offset: offsets[i]}
139- }
140- vertexInput.VertexBindingDescriptionCount = 1
141- vertexInput.VertexBindingDescriptions = &binding
142- vertexInput.VertexAttributeDescriptionCount = uint32(len(attrs))
143- vertexInput.VertexAttributeDescriptions = &attrs[0]
144- }
106+ binding := vulkan.VertexInputBindingDescription{Stride: uint32(unsafe.Sizeof(Instance{})), InputRate: vulkan.VertexInputRateInstance}
107+ offsets := [13]uint32{uint32(unsafe.Offsetof(Instance{}.Bounds)), uint32(unsafe.Offsetof(Instance{}.Color)), uint32(unsafe.Offsetof(Instance{}.Clip)), uint32(unsafe.Offsetof(Instance{}.Radii)), uint32(unsafe.Offsetof(Instance{}.UV)), uint32(unsafe.Offsetof(Instance{}.KindLayer)), uint32(unsafe.Offsetof(Instance{}.Widths)), uint32(unsafe.Offsetof(Instance{}.Sides)), uint32(unsafe.Offsetof(Instance{}.Sides)) + 16, uint32(unsafe.Offsetof(Instance{}.Sides)) + 32, uint32(unsafe.Offsetof(Instance{}.Sides)) + 48, uint32(unsafe.Offsetof(Instance{}.Shape)), uint32(unsafe.Offsetof(Instance{}.Shadow))}
108+ for i := range attrs {
109+ attrs[i] = vulkan.VertexInputAttributeDescription{Location: uint32(i), Format: vulkan.FormatR32g32b32a32Sfloat, Offset: offsets[i]}
110+ }
111+ vertexInput.VertexBindingDescriptionCount = 1
112+ vertexInput.VertexBindingDescriptions = &binding
113+ vertexInput.VertexAttributeDescriptionCount = uint32(len(attrs))
114+ vertexInput.VertexAttributeDescriptions = &attrs[0]
145115 assembly := vulkan.PipelineInputAssemblyStateCreateInfo{SType: vulkan.StructureTypePipelineInputAssemblyStateCreateInfo, Topology: vulkan.PrimitiveTopologyTriangleList}
146116 viewport := vulkan.Viewport{Width: float32(width), Height: float32(height), MaxDepth: 1}
147117 scissor := vulkan.Rect2D{Extent: vulkan.Extent2D{Width: uint32(width), Height: uint32(height)}}
@@ -154,7 +124,7 @@ func (d *Device) newPipeline(width, height int32, list bool) (p *Pipeline, err e
154124 rendering := vulkan.PipelineRenderingCreateInfo{SType: vulkan.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format}
155125 ci := vulkan.GraphicsPipelineCreateInfo{SType: vulkan.StructureTypeGraphicsPipelineCreateInfo, Next: unsafe.Pointer(&rendering), StageCount: 2, Stages: &stages[0], VertexInputState: &vertexInput, InputAssemblyState: &assembly, ViewportState: &vp, RasterizationState: &raster, MultisampleState: &ms, ColorBlendState: &blend, Layout: p.Layout}
156126 if err = vulkan.Check(d.Dispatch.CreateGraphicsPipelines(d.Logical, 0, 1, &ci, nil, &p.Handle)); err != nil {
157- return nil, fmt.Errorf("create rectangle pipeline with dynamic rendering: %w", err)
127+ return nil, fmt.Errorf("create list pipeline with dynamic rendering: %w", err)
158128 }
159129 return p, nil
160130 }
D internal/presentation/vkdevice/pipeline_test.go +0 -35
@@ -1,35 +0,0 @@
1-//go:build linux
2-
3-package vkdevice
4-
5-import (
6- "os"
7- "testing"
8-
9- "golang.org/x/sys/unix"
10-)
11-
12-func TestRectPipelineHardware(t *testing.T) {
13- path := os.Getenv("NEFERGUI_RENDER_NODE")
14- if path == "" {
15- t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
16- }
17- var stat unix.Stat_t
18- if err := unix.Stat(path, &stat); err != nil {
19- t.Fatal(err)
20- }
21- d, err := Open(DeviceIdentity(uint64(stat.Rdev)))
22- if err != nil {
23- t.Fatal(err)
24- }
25- defer d.Close()
26- p, err := d.NewRectPipeline(320, 240)
27- if err != nil {
28- t.Fatal(err)
29- }
30- defer p.Close()
31- if p.Handle == 0 || p.Layout == 0 {
32- t.Fatal("pipeline not created")
33- }
34- t.Logf("created 320x240 rectangle pipeline with dynamic rendering on %s", path)
35-}
M internal/presentation/vkdevice/readback.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 "github.com/bnema/purego-vulkan/vulkan"
1212 )
1313
14-// Readback is only allocated when NEFERGUI_DEBUG_DIR is set. Its transfer copy is
14+// Readback is only allocated for tests. Its transfer copy is
1515 // recorded after drawing and before the acquire semaphore signals; mapping
1616 // waits for the submission fence, never for compositor release.
1717 type Readback struct {
M internal/presentation/vkdevice/render.go +30 -43
@@ -76,17 +76,13 @@ func (f *Frame) Ready() (bool, error) {
7676 return true, nil
7777 }
7878
79-func (f *Frame) Record(img *Image, pipe *Pipeline, width, height int32, rect Rect, initial bool, capture bool) error {
80- return f.record(img, pipe, width, height, initial, capture, nil, nil, nil, &rect)
81-}
82-
8379 // RecordList records an ordered list of physical-pixel quads. Reuse is gated
8480 // by Ready, including any instance-buffer growth.
8581 func (f *Frame) RecordList(img *Image, pipe *Pipeline, width, height int32, instances []Instance, uploads []text.Upload, batches []Batch, initial, capture bool) error {
86- return f.record(img, pipe, width, height, initial, capture, instances, uploads, batches, nil)
82+ return f.record(img, pipe, width, height, initial, capture, instances, uploads, batches)
8783 }
8884
89-func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, capture bool, instances []Instance, uploads []text.Upload, batches []Batch, rect *Rect) (err error) {
85+func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, capture bool, instances []Instance, uploads []text.Upload, batches []Batch) (err error) {
9086 if width <= 0 || height <= 0 || pipe == nil {
9187 return fmt.Errorf("invalid render dimensions or pipeline")
9288 }
@@ -109,15 +105,13 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
109105 }
110106 f.submitted = false
111107 }
112- if rect == nil && f.device.atlas == nil {
108+ if f.device.atlas == nil {
113109 return fmt.Errorf("list atlas not initialized")
114110 }
115- if rect == nil {
116- if err := f.stageGlyphs(uploads); err != nil {
117- return err
118- }
111+ if err := f.stageGlyphs(uploads); err != nil {
112+ return err
119113 }
120- if rect == nil && f.device.images != nil {
114+ if f.device.images != nil {
121115 defer func() {
122116 if err != nil {
123117 f.device.images.abort(f)
@@ -128,7 +122,7 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
128122 return err
129123 }
130124 }
131- if rect == nil && len(instances) > 0 {
125+ if len(instances) > 0 {
132126 if err := f.uploadInstances(instances); err != nil {
133127 return err
134128 }
@@ -138,12 +132,10 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
138132 if err = vulkan.Check(d.BeginCommandBuffer(f.Commands, &begin)); err != nil {
139133 return err
140134 }
141- f.atlasRecorded = rect == nil && !f.device.atlas.initialized
142- if rect == nil {
143- var staging vulkan.Buffer
144- if len(uploads) != 0 {
145- staging = f.glyphStaging.Buffer
146- }
135+ f.atlasRecorded = !f.device.atlas.initialized
136+ var staging vulkan.Buffer
137+ if len(uploads) != 0 {
138+ staging = f.glyphStaging.Buffer
147139 f.device.atlas.recordUploads(f.Commands, staging, uploads)
148140 if !f.device.images.fallbackReady {
149141 f.recordFallback()
@@ -164,32 +156,27 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
164156 rendering := vulkan.RenderingInfo{SType: vulkan.StructureTypeRenderingInfo, RenderArea: area, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
165157 d.CmdBeginRendering(f.Commands, &rendering)
166158 d.CmdBindPipeline(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Handle)
167- if rect != nil {
168- d.CmdPushConstants(f.Commands, pipe.Layout, vulkan.ShaderStageVertexBit|vulkan.ShaderStageFragmentBit, 0, uint32(unsafe.Sizeof(*rect)), unsafe.Pointer(rect))
169- d.CmdDraw(f.Commands, 6, 1, 0, 0)
170- } else {
171- // The fragment shader statically references both descriptor sets even
172- // for non-image draws. Bind a valid initialized image set before any
173- // draw; each image batch then replaces set 1 in painter's order.
174- sets := [2]vulkan.DescriptorSet{f.device.atlas.Set, f.device.images.fallback.Texture.Set}
175- d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 0, 2, &sets[0], 0, nil)
176- screen := [2]float32{float32(width), float32(height)}
177- d.CmdPushConstants(f.Commands, pipe.Layout, vulkan.ShaderStageVertexBit|vulkan.ShaderStageFragmentBit, 0, 8, unsafe.Pointer(&screen))
178- if len(instances) > 0 {
179- offset := vulkan.DeviceSize(0)
180- d.CmdBindVertexBuffers(f.Commands, 0, 1, &f.instances.Buffer, &offset)
181- for _, batch := range batches {
182- if batch.Count == 0 || uint64(batch.First)+uint64(batch.Count) > uint64(len(instances)) {
183- return fmt.Errorf("invalid list batch range")
184- }
185- if batch.Source != nil {
186- if batch.Texture == nil {
187- return fmt.Errorf("image batch has no GPU texture")
188- }
189- d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 1, 1, &batch.Texture.Set, 0, nil)
159+ // The fragment shader statically references both descriptor sets even
160+ // for non-image draws. Bind a valid initialized image set before any
161+ // draw; each image batch then replaces set 1 in painter's order.
162+ sets := [2]vulkan.DescriptorSet{f.device.atlas.Set, f.device.images.fallback.Texture.Set}
163+ d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 0, 2, &sets[0], 0, nil)
164+ screen := [2]float32{float32(width), float32(height)}
165+ d.CmdPushConstants(f.Commands, pipe.Layout, vulkan.ShaderStageVertexBit|vulkan.ShaderStageFragmentBit, 0, 8, unsafe.Pointer(&screen))
166+ if len(instances) > 0 {
167+ offset := vulkan.DeviceSize(0)
168+ d.CmdBindVertexBuffers(f.Commands, 0, 1, &f.instances.Buffer, &offset)
169+ for _, batch := range batches {
170+ if batch.Count == 0 || uint64(batch.First)+uint64(batch.Count) > uint64(len(instances)) {
171+ return fmt.Errorf("invalid list batch range")
172+ }
173+ if batch.Source != nil {
174+ if batch.Texture == nil {
175+ return fmt.Errorf("image batch has no GPU texture")
190176 }
191- d.CmdDraw(f.Commands, 6, batch.Count, 0, batch.First)
177+ d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 1, 1, &batch.Texture.Set, 0, nil)
192178 }
179+ d.CmdDraw(f.Commands, 6, batch.Count, 0, batch.First)
193180 }
194181 }
195182 d.CmdEndRendering(f.Commands)
M internal/presentation/vkdevice/validation_test.go +1 -1
@@ -29,7 +29,7 @@ func TestValidationLayer(t *testing.T) {
2929 }
3030 t.Fatal(err)
3131 }
32- pipeline, err := d.NewRectPipeline(64, 64)
32+ pipeline, err := d.NewListPipeline(64, 64)
3333 if err != nil {
3434 d.Close()
3535 t.Fatal(err)
M internal/render/instances.go +1 -1
@@ -30,7 +30,7 @@ func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats)
3030 // The slices it returns alias its storage: they are valid only until the next
3131 // List or Release call, and must not be retained, shared across goroutines, or
3232 // used while another List may run. This is safe for consumers that copy the
33-// data synchronously (Session.tick uploads instances into GPU memory and
33+// data synchronously (Target.submit uploads instances into GPU memory and
3434 // resolve copies the batches before recording returns); anything that keeps the
3535 // slices past that point must use ListBatches instead. Retained frames hold
3636 // only render.Frame, never these slices, so a blocked frame is re-listed on retry.
A internal/ui/AsyncClipboard_mock_test.go +161 -0
@@ -0,0 +1,161 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package ui
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockAsyncClipboard creates a new instance of MockAsyncClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockAsyncClipboard(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockAsyncClipboard {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &MockAsyncClipboard{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// MockAsyncClipboard is an autogenerated mock type for the AsyncClipboard type
37+type MockAsyncClipboard struct {
38+ mock.Mock
39+}
40+
41+type MockAsyncClipboard_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *MockAsyncClipboard) EXPECT() *MockAsyncClipboard_Expecter {
46+ return &MockAsyncClipboard_Expecter{mock: &_m.Mock}
47+}
48+
49+// ReadTextAsync provides a mock function for the type MockAsyncClipboard
50+func (_mock *MockAsyncClipboard) ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error {
51+ ret := _mock.Called(ctx, limit, deliver)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for ReadTextAsync")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, func([]byte, error)) error); ok {
59+ r0 = returnFunc(ctx, limit, deliver)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// MockAsyncClipboard_ReadTextAsync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTextAsync'
67+type MockAsyncClipboard_ReadTextAsync_Call struct {
68+ *mock.Call
69+}
70+
71+// ReadTextAsync is a helper method to define mock.On call
72+// - ctx context.Context
73+// - limit int
74+// - deliver func([]byte, error)
75+func (_e *MockAsyncClipboard_Expecter) ReadTextAsync(ctx any, limit any, deliver any) *MockAsyncClipboard_ReadTextAsync_Call {
76+ return &MockAsyncClipboard_ReadTextAsync_Call{Call: _e.mock.On("ReadTextAsync", ctx, limit, deliver)}
77+}
78+
79+func (_c *MockAsyncClipboard_ReadTextAsync_Call) Run(run func(ctx context.Context, limit int, deliver func([]byte, error))) *MockAsyncClipboard_ReadTextAsync_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 int
86+ if args[1] != nil {
87+ arg1 = args[1].(int)
88+ }
89+ var arg2 func([]byte, error)
90+ if args[2] != nil {
91+ arg2 = args[2].(func([]byte, error))
92+ }
93+ run(
94+ arg0,
95+ arg1,
96+ arg2,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockAsyncClipboard_ReadTextAsync_Call) Return(err error) *MockAsyncClipboard_ReadTextAsync_Call {
103+ _c.Call.Return(err)
104+ return _c
105+}
106+
107+func (_c *MockAsyncClipboard_ReadTextAsync_Call) RunAndReturn(run func(ctx context.Context, limit int, deliver func([]byte, error)) error) *MockAsyncClipboard_ReadTextAsync_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
111+
112+// WriteText provides a mock function for the type MockAsyncClipboard
113+func (_mock *MockAsyncClipboard) WriteText(bytes []byte) error {
114+ ret := _mock.Called(bytes)
115+
116+ if len(ret) == 0 {
117+ panic("no return value specified for WriteText")
118+ }
119+
120+ var r0 error
121+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
122+ r0 = returnFunc(bytes)
123+ } else {
124+ r0 = ret.Error(0)
125+ }
126+ return r0
127+}
128+
129+// MockAsyncClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
130+type MockAsyncClipboard_WriteText_Call struct {
131+ *mock.Call
132+}
133+
134+// WriteText is a helper method to define mock.On call
135+// - bytes []byte
136+func (_e *MockAsyncClipboard_Expecter) WriteText(bytes any) *MockAsyncClipboard_WriteText_Call {
137+ return &MockAsyncClipboard_WriteText_Call{Call: _e.mock.On("WriteText", bytes)}
138+}
139+
140+func (_c *MockAsyncClipboard_WriteText_Call) Run(run func(bytes []byte)) *MockAsyncClipboard_WriteText_Call {
141+ _c.Call.Run(func(args mock.Arguments) {
142+ var arg0 []byte
143+ if args[0] != nil {
144+ arg0 = args[0].([]byte)
145+ }
146+ run(
147+ arg0,
148+ )
149+ })
150+ return _c
151+}
152+
153+func (_c *MockAsyncClipboard_WriteText_Call) Return(err error) *MockAsyncClipboard_WriteText_Call {
154+ _c.Call.Return(err)
155+ return _c
156+}
157+
158+func (_c *MockAsyncClipboard_WriteText_Call) RunAndReturn(run func(bytes []byte) error) *MockAsyncClipboard_WriteText_Call {
159+ _c.Call.Return(run)
160+ return _c
161+}
A internal/ui/Clipboard_mock_test.go +158 -0
@@ -0,0 +1,158 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package ui
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockClipboard(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockClipboard {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockClipboard{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockClipboard is an autogenerated mock type for the Clipboard type
35+type MockClipboard struct {
36+ mock.Mock
37+}
38+
39+type MockClipboard_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockClipboard) EXPECT() *MockClipboard_Expecter {
44+ return &MockClipboard_Expecter{mock: &_m.Mock}
45+}
46+
47+// ReadText provides a mock function for the type MockClipboard
48+func (_mock *MockClipboard) ReadText(limit int) ([]byte, error) {
49+ ret := _mock.Called(limit)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for ReadText")
53+ }
54+
55+ var r0 []byte
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func(int) ([]byte, error)); ok {
58+ return returnFunc(limit)
59+ }
60+ if returnFunc, ok := ret.Get(0).(func(int) []byte); ok {
61+ r0 = returnFunc(limit)
62+ } else {
63+ if ret.Get(0) != nil {
64+ r0 = ret.Get(0).([]byte)
65+ }
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
68+ r1 = returnFunc(limit)
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// MockClipboard_ReadText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadText'
76+type MockClipboard_ReadText_Call struct {
77+ *mock.Call
78+}
79+
80+// ReadText is a helper method to define mock.On call
81+// - limit int
82+func (_e *MockClipboard_Expecter) ReadText(limit any) *MockClipboard_ReadText_Call {
83+ return &MockClipboard_ReadText_Call{Call: _e.mock.On("ReadText", limit)}
84+}
85+
86+func (_c *MockClipboard_ReadText_Call) Run(run func(limit int)) *MockClipboard_ReadText_Call {
87+ _c.Call.Run(func(args mock.Arguments) {
88+ var arg0 int
89+ if args[0] != nil {
90+ arg0 = args[0].(int)
91+ }
92+ run(
93+ arg0,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockClipboard_ReadText_Call) Return(bytes []byte, err error) *MockClipboard_ReadText_Call {
100+ _c.Call.Return(bytes, err)
101+ return _c
102+}
103+
104+func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(limit int) ([]byte, error)) *MockClipboard_ReadText_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// WriteText provides a mock function for the type MockClipboard
110+func (_mock *MockClipboard) WriteText(bytes []byte) error {
111+ ret := _mock.Called(bytes)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for WriteText")
115+ }
116+
117+ var r0 error
118+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
119+ r0 = returnFunc(bytes)
120+ } else {
121+ r0 = ret.Error(0)
122+ }
123+ return r0
124+}
125+
126+// MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
127+type MockClipboard_WriteText_Call struct {
128+ *mock.Call
129+}
130+
131+// WriteText is a helper method to define mock.On call
132+// - bytes []byte
133+func (_e *MockClipboard_Expecter) WriteText(bytes any) *MockClipboard_WriteText_Call {
134+ return &MockClipboard_WriteText_Call{Call: _e.mock.On("WriteText", bytes)}
135+}
136+
137+func (_c *MockClipboard_WriteText_Call) Run(run func(bytes []byte)) *MockClipboard_WriteText_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ var arg0 []byte
140+ if args[0] != nil {
141+ arg0 = args[0].([]byte)
142+ }
143+ run(
144+ arg0,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *MockClipboard_WriteText_Call) Return(err error) *MockClipboard_WriteText_Call {
151+ _c.Call.Return(err)
152+ return _c
153+}
154+
155+func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(bytes []byte) error) *MockClipboard_WriteText_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
A internal/ui/IME_mock_test.go +267 -0
@@ -0,0 +1,267 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package ui
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockIME creates a new instance of MockIME. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockIME(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockIME {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockIME{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockIME is an autogenerated mock type for the IME type
35+type MockIME struct {
36+ mock.Mock
37+}
38+
39+type MockIME_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockIME) EXPECT() *MockIME_Expecter {
44+ return &MockIME_Expecter{mock: &_m.Mock}
45+}
46+
47+// ContentType provides a mock function for the type MockIME
48+func (_mock *MockIME) ContentType(password bool, multiline bool) {
49+ _mock.Called(password, multiline)
50+ return
51+}
52+
53+// MockIME_ContentType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ContentType'
54+type MockIME_ContentType_Call struct {
55+ *mock.Call
56+}
57+
58+// ContentType is a helper method to define mock.On call
59+// - password bool
60+// - multiline bool
61+func (_e *MockIME_Expecter) ContentType(password any, multiline any) *MockIME_ContentType_Call {
62+ return &MockIME_ContentType_Call{Call: _e.mock.On("ContentType", password, multiline)}
63+}
64+
65+func (_c *MockIME_ContentType_Call) Run(run func(password bool, multiline bool)) *MockIME_ContentType_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 bool
68+ if args[0] != nil {
69+ arg0 = args[0].(bool)
70+ }
71+ var arg1 bool
72+ if args[1] != nil {
73+ arg1 = args[1].(bool)
74+ }
75+ run(
76+ arg0,
77+ arg1,
78+ )
79+ })
80+ return _c
81+}
82+
83+func (_c *MockIME_ContentType_Call) Return() *MockIME_ContentType_Call {
84+ _c.Call.Return()
85+ return _c
86+}
87+
88+func (_c *MockIME_ContentType_Call) RunAndReturn(run func(password bool, multiline bool)) *MockIME_ContentType_Call {
89+ _c.Run(run)
90+ return _c
91+}
92+
93+// CursorRect provides a mock function for the type MockIME
94+func (_mock *MockIME) CursorRect(x float64, y float64, w float64, h float64) {
95+ _mock.Called(x, y, w, h)
96+ return
97+}
98+
99+// MockIME_CursorRect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CursorRect'
100+type MockIME_CursorRect_Call struct {
101+ *mock.Call
102+}
103+
104+// CursorRect is a helper method to define mock.On call
105+// - x float64
106+// - y float64
107+// - w float64
108+// - h float64
109+func (_e *MockIME_Expecter) CursorRect(x any, y any, w any, h any) *MockIME_CursorRect_Call {
110+ return &MockIME_CursorRect_Call{Call: _e.mock.On("CursorRect", x, y, w, h)}
111+}
112+
113+func (_c *MockIME_CursorRect_Call) Run(run func(x float64, y float64, w float64, h float64)) *MockIME_CursorRect_Call {
114+ _c.Call.Run(func(args mock.Arguments) {
115+ var arg0 float64
116+ if args[0] != nil {
117+ arg0 = args[0].(float64)
118+ }
119+ var arg1 float64
120+ if args[1] != nil {
121+ arg1 = args[1].(float64)
122+ }
123+ var arg2 float64
124+ if args[2] != nil {
125+ arg2 = args[2].(float64)
126+ }
127+ var arg3 float64
128+ if args[3] != nil {
129+ arg3 = args[3].(float64)
130+ }
131+ run(
132+ arg0,
133+ arg1,
134+ arg2,
135+ arg3,
136+ )
137+ })
138+ return _c
139+}
140+
141+func (_c *MockIME_CursorRect_Call) Return() *MockIME_CursorRect_Call {
142+ _c.Call.Return()
143+ return _c
144+}
145+
146+func (_c *MockIME_CursorRect_Call) RunAndReturn(run func(x float64, y float64, w float64, h float64)) *MockIME_CursorRect_Call {
147+ _c.Run(run)
148+ return _c
149+}
150+
151+// Disable provides a mock function for the type MockIME
152+func (_mock *MockIME) Disable() {
153+ _mock.Called()
154+ return
155+}
156+
157+// MockIME_Disable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Disable'
158+type MockIME_Disable_Call struct {
159+ *mock.Call
160+}
161+
162+// Disable is a helper method to define mock.On call
163+func (_e *MockIME_Expecter) Disable() *MockIME_Disable_Call {
164+ return &MockIME_Disable_Call{Call: _e.mock.On("Disable")}
165+}
166+
167+func (_c *MockIME_Disable_Call) Run(run func()) *MockIME_Disable_Call {
168+ _c.Call.Run(func(args mock.Arguments) {
169+ run()
170+ })
171+ return _c
172+}
173+
174+func (_c *MockIME_Disable_Call) Return() *MockIME_Disable_Call {
175+ _c.Call.Return()
176+ return _c
177+}
178+
179+func (_c *MockIME_Disable_Call) RunAndReturn(run func()) *MockIME_Disable_Call {
180+ _c.Run(run)
181+ return _c
182+}
183+
184+// Enable provides a mock function for the type MockIME
185+func (_mock *MockIME) Enable() {
186+ _mock.Called()
187+ return
188+}
189+
190+// MockIME_Enable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enable'
191+type MockIME_Enable_Call struct {
192+ *mock.Call
193+}
194+
195+// Enable is a helper method to define mock.On call
196+func (_e *MockIME_Expecter) Enable() *MockIME_Enable_Call {
197+ return &MockIME_Enable_Call{Call: _e.mock.On("Enable")}
198+}
199+
200+func (_c *MockIME_Enable_Call) Run(run func()) *MockIME_Enable_Call {
201+ _c.Call.Run(func(args mock.Arguments) {
202+ run()
203+ })
204+ return _c
205+}
206+
207+func (_c *MockIME_Enable_Call) Return() *MockIME_Enable_Call {
208+ _c.Call.Return()
209+ return _c
210+}
211+
212+func (_c *MockIME_Enable_Call) RunAndReturn(run func()) *MockIME_Enable_Call {
213+ _c.Run(run)
214+ return _c
215+}
216+
217+// Surrounding provides a mock function for the type MockIME
218+func (_mock *MockIME) Surrounding(text string, cursor int, anchor int) {
219+ _mock.Called(text, cursor, anchor)
220+ return
221+}
222+
223+// MockIME_Surrounding_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Surrounding'
224+type MockIME_Surrounding_Call struct {
225+ *mock.Call
226+}
227+
228+// Surrounding is a helper method to define mock.On call
229+// - text string
230+// - cursor int
231+// - anchor int
232+func (_e *MockIME_Expecter) Surrounding(text any, cursor any, anchor any) *MockIME_Surrounding_Call {
233+ return &MockIME_Surrounding_Call{Call: _e.mock.On("Surrounding", text, cursor, anchor)}
234+}
235+
236+func (_c *MockIME_Surrounding_Call) Run(run func(text string, cursor int, anchor int)) *MockIME_Surrounding_Call {
237+ _c.Call.Run(func(args mock.Arguments) {
238+ var arg0 string
239+ if args[0] != nil {
240+ arg0 = args[0].(string)
241+ }
242+ var arg1 int
243+ if args[1] != nil {
244+ arg1 = args[1].(int)
245+ }
246+ var arg2 int
247+ if args[2] != nil {
248+ arg2 = args[2].(int)
249+ }
250+ run(
251+ arg0,
252+ arg1,
253+ arg2,
254+ )
255+ })
256+ return _c
257+}
258+
259+func (_c *MockIME_Surrounding_Call) Return() *MockIME_Surrounding_Call {
260+ _c.Call.Return()
261+ return _c
262+}
263+
264+func (_c *MockIME_Surrounding_Call) RunAndReturn(run func(text string, cursor int, anchor int)) *MockIME_Surrounding_Call {
265+ _c.Run(run)
266+ return _c
267+}
M internal/ui/controls.go +33 -0
@@ -3,6 +3,7 @@ package ui
33 import (
44 "fmt"
55 "github.com/bnema/nefergui/internal/edit"
6+ "strconv"
67 "strings"
78 )
89
@@ -231,3 +232,35 @@ func (n Node) Heading(text string, options ...HeadingOption) {
231232 }
232233 }
233234 }
235+
236+// TextInt shows prefix followed by v in decimal without fmt boxing. A prefix
237+// up to 76 bytes is built in a stack buffer, so the only allocation is the
238+// final string; a longer prefix costs one more.
239+func (n Node) TextInt(prefix string, v int64, options ...ContainerOption) {
240+ child := n.child("text", "", options)
241+ if !child.valid() {
242+ return
243+ }
244+ var stack [96]byte
245+ buf := append(stack[:0], prefix...) // grows on the heap for a long prefix
246+ buf = strconv.AppendInt(buf, v, 10)
247+ child.element.text = string(buf)
248+}
249+
250+// maskBullets backs Masked for common counts without allocating.
251+var maskBullets = strings.Repeat("•", 256)
252+
253+// Masked shows count bullet glyphs and stores no value, for a secret held
254+// outside the view (a password field fed from an external buffer). A negative
255+// count shows nothing.
256+func (n Node) Masked(count int, options ...ContainerOption) {
257+ child := n.child("text", "", options)
258+ if !child.valid() || count <= 0 {
259+ return
260+ }
261+ if count <= 256 {
262+ child.element.text = maskBullets[:count*len("•")]
263+ return
264+ }
265+ child.element.text = strings.Repeat("•", count)
266+}
M internal/ui/controls_extra_test.go +3 -8
@@ -3,14 +3,9 @@ package ui
33 import (
44 "github.com/bnema/nefergui/internal/css"
55 "github.com/bnema/nefergui/internal/edit"
6- "github.com/bnema/nefergui/internal/keyboard"
76 "testing"
87 )
98
10-type clip struct{ data []byte }
11-
12-func (c *clip) ReadText(limit int) ([]byte, error) { return c.data, nil }
13-func (c *clip) WriteText(v []byte) error { c.data = append([]byte(nil), v...); return nil }
149 func TestRadioSliderAndEditingIdentity(t *testing.T) {
1510 r := newRuntime()
1611 r.styles = css.Compile(css.UA(), css.Parse(`input,radio,slider {height:20px}`))
@@ -85,7 +80,7 @@ func TestDisabledEditorRejectsFocusAndText(t *testing.T) {
8580 if r.state.focus != nil {
8681 t.Fatalf("disabled input focused: %s", idKey(r.state.focus))
8782 }
88- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})
83+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
8984 r.Build(view)
9085 if value != "keep" {
9186 t.Fatalf("disabled input edited: %q", value)
@@ -98,10 +93,10 @@ func TestEditFocusIMEAndClipboard(t *testing.T) {
9893 var value string
9994 view := func(f *Frame) { f.Root().Input("secret", &value, Key("secret"), Password(true)) }
10095 r.Build(view)
101- r.clipboard = &clip{data: []byte("paste")}
96+ r.clipboard = NewMockClipboard(t) // no clipboard access expected
10297 r.route(key("Tab"))
10398 r.Build(view)
104- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})
99+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
105100 r.Build(view)
106101 if value != "x" || r.committed.children[0].text != "•" {
107102 t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus))
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 -277
@@ -1,277 +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-// InputKind classifies an InputEvent.
16-type InputKind uint8
17-
18-const (
19- InputPointerMotion InputKind = iota + 1
20- InputPointerPress
21- InputPointerRelease
22- InputPointerAxis
23- InputPointerLeave
24- InputKey
25- InputFocusIn
26- InputFocusOut
27- InputReset // platform dropped queued input; treat held state as released
28-)
29-
30-// Modifiers is a bit set of keyboard modifiers held during a key event.
31-type Modifiers uint8
32-
33-const (
34- ModShift Modifiers = 1 << iota
35- ModCtrl
36-)
37-
38-// InputEvent is a raw platform event, delivered on the owner loop before the
39-// normal control routing. Coordinates are logical, surface-local pixels.
40-// Size changes are not input; use OnResize and Frame.Size. Which fields are set
41-// depends on Kind:
42-//
43-// pointer motion/press/release/axis: X, Y; press/release: Button (evdev code)
44-// and Pressed; axis: DX, DY
45-// key: KeyName, Text, Modifiers, Pressed, Repeat
46-type InputEvent struct {
47- Kind InputKind
48- X, Y float64
49- DX, DY float64
50- Button uint32
51- Pressed bool
52- Repeat bool
53- KeyName string
54- Text string
55- Modifiers Modifiers
56-}
57-
58-// OnInput delivers every raw input event to fn on the session owner loop,
59-// before the event reaches NeferGUI controls; controls still receive it. fn may
60-// mutate the model the view reads. It returns true when it changed state the
61-// view shows, which schedules a redraw; a false return costs no frame, so
62-// pointer motion does not force a build. A nil fn is ignored. fn must not block.
63-func OnInput(fn func(InputEvent) bool) WindowOption {
64- return windowOption(func(w *windowConfig) {
65- if fn != nil {
66- w.onInput = fn
67- }
68- })
69-}
70-
71-// OnResize reports the logical size and scale once at start and again only
72-// when one of them changes; a change always schedules a redraw. It runs on the
73-// owner loop before the frame is built. A nil fn is ignored.
74-func OnResize(fn func(width, height int, scale float64)) WindowOption {
75- return windowOption(func(w *windowConfig) {
76- if fn != nil {
77- w.onResize = fn
78- }
79- })
80-}
81-
82-// Wake requests a redraw whenever a value arrives on ch, so a view can react
83-// to state changed by other goroutines. The caller synchronizes such state;
84-// the view still runs only on the owner loop. Closing ch stops forwarding. A nil ch is ignored.
85-func Wake(ch <-chan struct{}) WindowOption {
86- return windowOption(func(w *windowConfig) {
87- if ch != nil {
88- w.wake = ch
89- }
90- })
91-}
92-
93-// inputHooks holds the optional callbacks; all methods run on the owner loop.
94-type inputHooks struct {
95- onInput func(InputEvent) bool
96- onResize func(int, int, float64)
97- haveSize bool
98- width, height int
99- scale float64
100-}
101-
102-func toInputEvent(in wayland.Input) (InputEvent, bool) {
103- ev := InputEvent{X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button}
104- switch in.Kind {
105- case "motion":
106- ev.Kind = InputPointerMotion
107- case "press":
108- ev.Kind, ev.Pressed = InputPointerPress, true
109- case "release":
110- ev.Kind = InputPointerRelease
111- case "axis":
112- ev.Kind = InputPointerAxis
113- case "leave":
114- ev.Kind = InputPointerLeave
115- case "key":
116- ev.Kind = InputKey
117- ev.KeyName, ev.Text = in.Key.Name, in.Key.Text
118- ev.Pressed, ev.Repeat = in.Key.Pressed, in.Key.Repeat
119- if in.Key.Shift {
120- ev.Modifiers |= ModShift
121- }
122- if in.Key.Ctrl {
123- ev.Modifiers |= ModCtrl
124- }
125- case "focus-in":
126- ev.Kind = InputFocusIn
127- case "focus-out":
128- ev.Kind = InputFocusOut
129- case wayland.InputReset:
130- ev.Kind = InputReset
131- default:
132- return InputEvent{}, false
133- }
134- return ev, true
135-}
136-
137-// input reports one platform input and whether the hook changed view state;
138-// it never allocates.
139-func (h *inputHooks) input(in wayland.Input) bool {
140- if h.onInput == nil {
141- return false
142- }
143- if ev, ok := toInputEvent(in); ok {
144- return h.onInput(ev)
145- }
146- return false
147-}
148-
149-// resize reports a size or scale only when it differs from the last report and
150-// reports whether it did.
151-func (h *inputHooks) resize(width, height int, scale float64) bool {
152- if h.haveSize && h.width == width && h.height == height && h.scale == scale {
153- return false
154- }
155- h.haveSize, h.width, h.height, h.scale = true, width, height, scale
156- if h.onResize != nil {
157- h.onResize(width, height, scale)
158- }
159- return true
160-}
161-
162-// forwardWake turns external wake tokens into redraw requests. Redraw records
163-// the request before signalling the owner loop, so the next Build is not idle.
164-func (r *runtime) forwardWake(ctx context.Context, ch <-chan struct{}) {
165- for {
166- select {
167- case <-ctx.Done():
168- return
169- case _, ok := <-ch:
170- if !ok {
171- return
172- }
173- r.Redraw()
174- }
175- }
176-}
177-
178-// WaylandSurface exposes the window's Wayland connection and wl_surface so an
179-// integrator can register extra protocol objects on the same client, such as
180-// clipboard or surface-marking extensions. It is the escape hatch to the
181-// underlying wlturbo objects; it does not add any protocol itself.
182-//
183-// The values are owned by NeferGUI and valid until Run returns. Do not close
184-// the Display, destroy the Surface or attach buffers. Events for objects the
185-// callback creates are dispatched on the owner goroutine while requests such as
186-// roundtrips run before the event reader starts, and on NeferGUI's reader
187-// goroutine afterwards, so handlers must synchronize any state they share.
188-type WaylandSurface struct {
189- Display *wl.Display
190- Surface *core.Surface
191-}
192-
193-// OnSurface calls fn once on the owner goroutine, after the surface has its
194-// role (xdg toplevel or layer surface) and its first configure, and before the
195-// first buffer is attached, so the window is not yet visible. ctx is the Run
196-// context: if it is cancelled while fn runs, NeferGUI closes the display so
197-// blocking protocol calls fail, and Run returns ctx.Err() (joined with any
198-// other error fn returned). A non-nil error aborts Run and closes the session.
199-// A nil fn is ignored.
200-func OnSurface(fn func(context.Context, WaylandSurface) error) WindowOption {
201- return windowOption(func(w *windowConfig) {
202- if fn != nil {
203- w.onSurface = fn
204- }
205- })
206-}
207-
208-// SetInputRects stages the input region for the surface: pointer input outside
209-// the rectangles passes through. It is applied after the view returns and
210-// and committed together with the frame's buffer, with no extra commit. nil restores the whole surface, and a non-nil
211-// empty slice makes the surface click-through. Rects need positive size. The
212-// slice is copied, so callers may reuse it; an unchanged region costs nothing.
213-// If the view never calls it, the Layer config's InputRects stay in effect.
214-func (f *Frame) SetInputRects(rects []Rect) error {
215- if !f.active {
216- panic("nefergui: SetInputRects outside frame")
217- }
218- for i, r := range rects {
219- if r.Width <= 0 || r.Height <= 0 || int64(r.X)+int64(r.Width) > math.MaxInt32 || int64(r.Y)+int64(r.Height) > math.MaxInt32 {
220- return fmt.Errorf("nefergui: input rect %d %+v is empty or overflows", i, r)
221- }
222- }
223- o := f.owner
224- o.inputNil = rects == nil
225- o.inputRects = append(o.inputRects[:0], rects...)
226- o.inputStaged = true
227- return nil
228-}
229-
230-// applyInputRects hands a staged region to the platform's stage function, which
231-// must not commit: the region rides the frame's own buffer commit. The region
232-// stays staged until stage succeeds, so a failure is retried on the next frame.
233-func (r *runtime) applyInputRects(stage func([]wayland.Rect) error) error {
234- if !r.inputStaged {
235- return nil
236- }
237- if r.inputNil {
238- if err := stage(nil); err != nil {
239- return err
240- }
241- r.inputStaged = false
242- return nil
243- }
244- out := r.inputOut[:0]
245- if out == nil {
246- out = []wayland.Rect{}
247- }
248- for _, q := range r.inputRects {
249- out = append(out, wayland.Rect{X: q.X, Y: q.Y, W: q.Width, H: q.Height})
250- }
251- r.inputOut = out
252- if err := stage(out); err != nil {
253- return err
254- }
255- r.inputStaged = false
256- return nil
257-}
258-
259-// startupHook runs the OnSurface callback. When ctx can be cancelled, the
260-// display is closed on cancellation so blocking calls in fn return; a
261-// cancelled ctx is reported joined with fn's own error.
262-func startupHook(ctx context.Context, fn func(context.Context, WaylandSurface) error, s WaylandSurface) error {
263- if ctx.Done() != nil {
264- closed := make(chan struct{})
265- stop := context.AfterFunc(ctx, func() { _ = s.Display.Close(); close(closed) })
266- defer func() {
267- if !stop() {
268- <-closed // the close callback already started; let it finish
269- }
270- }()
271- }
272- err := fn(ctx, s)
273- if cerr := ctx.Err(); cerr != nil {
274- return errors.Join(cerr, err)
275- }
276- return err
277-}
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-}
M internal/ui/ime_focus_test.go +43 -41
@@ -3,60 +3,62 @@ package ui
33 import (
44 "reflect"
55 "testing"
6-)
7-
8-type orderedIME struct {
9- calls []string
10- active bool
11-}
12-
13-func (i *orderedIME) Enable() {
14- if i.active {
15- panic("IME enable before disable")
16- }
17- i.active = true
18- i.calls = append(i.calls, "enable")
19-}
20-func (i *orderedIME) Disable() {
21- if !i.active {
22- panic("IME double disable")
23- }
24- i.active = false
25- i.calls = append(i.calls, "disable")
26-}
27-func (i *orderedIME) Surrounding(string, int, int) {}
28-func (i *orderedIME) CursorRect(float64, float64, float64, float64) {}
29-func (i *orderedIME) ContentType(bool, bool) {}
306
31-type recordingIME struct {
32- surrounding []string
33- password bool
34-}
7+ "github.com/stretchr/testify/mock"
8+)
359
36-func (i *recordingIME) Enable() {}
37-func (i *recordingIME) Disable() {}
38-func (i *recordingIME) Surrounding(text string, _, _ int) {
39- i.surrounding = append(i.surrounding, text)
10+// newOrderedIME returns an IME mock that records Enable and Disable in order
11+// and fails on an enable before a disable or a double disable.
12+func newOrderedIME(t *testing.T) (*MockIME, *[]string) {
13+ t.Helper()
14+ ime := NewMockIME(t)
15+ calls := new([]string)
16+ active := false
17+ ime.EXPECT().Enable().Run(func() {
18+ if active {
19+ t.Error("IME enable before disable")
20+ }
21+ active = true
22+ *calls = append(*calls, "enable")
23+ }).Maybe()
24+ ime.EXPECT().Disable().Run(func() {
25+ if !active {
26+ t.Error("IME double disable")
27+ }
28+ active = false
29+ *calls = append(*calls, "disable")
30+ }).Maybe()
31+ ime.EXPECT().Surrounding(mock.Anything, mock.Anything, mock.Anything).Maybe()
32+ ime.EXPECT().CursorRect(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe()
33+ ime.EXPECT().ContentType(mock.Anything, mock.Anything).Maybe()
34+ return ime, calls
4035 }
41-func (i *recordingIME) CursorRect(float64, float64, float64, float64) {}
42-func (i *recordingIME) ContentType(password, _ bool) { i.password = password }
4336
4437 func TestPasswordEditorNeverSendsSecretToIME(t *testing.T) {
4538 r := newRuntime()
46- ime := &recordingIME{}
39+ ime := NewMockIME(t)
40+ var surrounding []string
41+ password := false
42+ ime.EXPECT().Enable().Maybe()
43+ ime.EXPECT().Disable().Maybe()
44+ ime.EXPECT().CursorRect(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe()
45+ ime.EXPECT().Surrounding(mock.Anything, mock.Anything, mock.Anything).Run(func(text string, _, _ int) {
46+ surrounding = append(surrounding, text)
47+ }).Maybe()
48+ ime.EXPECT().ContentType(mock.Anything, mock.Anything).Run(func(p, _ bool) { password = p }).Maybe()
4749 r.ime = ime
4850 value := "hunter2"
4951 view := func(f *Frame) { f.Root().Input("secret", &value, Key("secret"), Password(true)) }
5052 r.Build(view)
5153 r.route(key("Tab"))
5254 r.Build(view)
53- if !ime.password {
55+ if !password {
5456 t.Fatal("password content type not sent")
5557 }
56- if len(ime.surrounding) == 0 {
58+ if len(surrounding) == 0 {
5759 t.Fatal("no surrounding text update")
5860 }
59- for _, s := range ime.surrounding {
61+ for _, s := range surrounding {
6062 if s != "" {
6163 t.Fatalf("surrounding text leaked %q", s)
6264 }
@@ -65,7 +67,7 @@ func TestPasswordEditorNeverSendsSecretToIME(t *testing.T) {
6567
6668 func TestIMEFocusTransferDisablesBeforeEnable(t *testing.T) {
6769 r := newRuntime()
68- ime := &orderedIME{}
70+ ime, calls := newOrderedIME(t)
6971 r.ime = ime
7072 a, b := "one", "two"
7173 view := func(f *Frame) { root := f.Root(); root.Input("a", &a, Key("a")); root.Textarea("b", &b, Key("b")) }
@@ -76,8 +78,8 @@ func TestIMEFocusTransferDisablesBeforeEnable(t *testing.T) {
7678 r.Build(view)
7779 r.route(key("Tab"))
7880 r.Build(view)
79- if want := []string{"enable", "disable", "enable", "disable", "enable"}; !reflect.DeepEqual(ime.calls, want) {
80- t.Fatalf("focus transition calls %v want %v", ime.calls, want)
81+ if want := []string{"enable", "disable", "enable", "disable", "enable"}; !reflect.DeepEqual(*calls, want) {
82+ t.Fatalf("focus transition calls %v want %v", *calls, want)
8183 }
8284 if !r.edits[idKey(r.Target(0))].IMEActive() || r.edits[idKey(r.Target(1))].IMEActive() {
8385 t.Fatal("wrong active editor")
M internal/ui/ime_return_test.go +3 -4
@@ -4,18 +4,17 @@ import (
44 "testing"
55
66 "github.com/bnema/nefergui/internal/edit"
7- "github.com/bnema/nefergui/internal/keyboard"
87 )
98
109 func typed(name, text string) platformInput {
11- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Text: text, Pressed: true}}
10+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Text: text, Pressed: true}}
1211 }
1312
1413 // With text-input v3 the compositor routes keys to a grabbing input method;
1514 // keys that still arrive on wl_keyboard were not consumed and must be typed.
1615 func TestKeyboardTextInsertedWhileIMEEnabled(t *testing.T) {
1716 r := newRuntime()
18- ime := &orderedIME{}
17+ ime, calls := newOrderedIME(t)
1918 r.ime = ime
2019 value := "ab"
2120 view := func(f *Frame) { f.Root().Textarea("message", &value) }
@@ -29,7 +28,7 @@ func TestKeyboardTextInsertedWhileIMEEnabled(t *testing.T) {
2928 r.route(k)
3029 r.Build(view)
3130 }
32- if value != "abca\n\n" || len(ime.calls) == 0 {
31+ if value != "abca\n\n" || len(*calls) == 0 {
3332 t.Fatalf("keyboard text lost while IME enabled: %q", value)
3433 }
3534
A internal/ui/input.go +26 -0
@@ -0,0 +1,26 @@
1+package ui
2+
3+// InputKind classifies an InputEvent.
4+type InputKind uint8
5+
6+const (
7+ InputPointerMotion InputKind = iota + 1
8+ InputPointerPress
9+ InputPointerRelease
10+ InputPointerAxis
11+ InputPointerLeave
12+ InputKey
13+ InputFocusIn
14+ InputFocusOut
15+ InputReset // platform dropped queued input; treat held state as released
16+)
17+
18+// Modifiers is a bit set of keyboard modifiers held during a key event.
19+type Modifiers uint8
20+
21+const (
22+ ModShift Modifiers = 1 << iota
23+ ModCtrl
24+ ModAlt
25+ ModSuper
26+)
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+}
A internal/ui/inputrects_test.go +133 -0
@@ -0,0 +1,133 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "math"
7+ "strconv"
8+ "testing"
9+
10+ "github.com/bnema/nefergui/internal/layout"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+func TestFrameSetInputRects(t *testing.T) {
15+ r, tg := newTestRenderer(t)
16+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
17+ drawOK(tg, nil).Maybe()
18+ r.Resize(200, 100, 1)
19+ var out Output
20+ var setErr error
21+ rects := []Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}
22+ render := func(view func(*Frame, *testModel)) bool {
23+ t.Helper()
24+ r.Invalidate()
25+ ok, err := r.Render(&out, &testModel{}, view)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ return ok
30+ }
31+ if !render(func(f *Frame, _ *testModel) { f.Root(); setErr = f.SetInputRects(rects) }) || setErr != nil {
32+ t.Fatalf("render or set: %v", setErr)
33+ }
34+ rects[0].Width = 99 // caller reuse must not affect the staged copy
35+ if !out.InputRectsChanged || len(out.InputRects) != 2 || out.InputRects[0] != (Rect{1, 2, 3, 4}) {
36+ t.Fatalf("applied %+v", out.InputRects)
37+ }
38+ // An unchanged region is not reported again.
39+ if render(func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects([]Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}) }) && out.InputRectsChanged {
40+ t.Fatalf("restaged %+v", out.InputRects)
41+ }
42+ // A frame that never calls SetInputRects stages nothing new.
43+ if render(func(f *Frame, _ *testModel) { f.Root() }) && out.InputRectsChanged {
44+ t.Fatal("unchanged frame restaged the region")
45+ }
46+ // nil restores the full surface; empty non-nil is click-through.
47+ if !render(func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects(nil) }) || !out.InputRectsChanged || out.InputRects != nil {
48+ t.Fatalf("nil region: %+v", out)
49+ }
50+ if !render(func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects([]Rect{}) }) || !out.InputRectsChanged || out.InputRects == nil || len(out.InputRects) != 0 {
51+ t.Fatalf("empty region: %+v", out)
52+ }
53+ // Invalid rects are rejected in the frame call and stage nothing.
54+ for _, bad := range []Rect{{0, 0, 0, 5}, {0, 0, 5, -1}, {math.MaxInt32, 0, 5, 5}} {
55+ render(func(f *Frame, _ *testModel) { f.Root(); setErr = f.SetInputRects([]Rect{bad}) })
56+ if setErr == nil || r.rt.inputStaged {
57+ t.Fatalf("accepted %+v", bad)
58+ }
59+ }
60+ // Steady-state staging does not allocate once buffers exist.
61+ rt := newRuntime()
62+ rt.Build(func(f *Frame) {
63+ f.Root()
64+ _ = f.SetInputRects(rects)
65+ if n := testing.AllocsPerRun(50, func() { _ = f.SetInputRects(rects) }); n != 0 {
66+ t.Fatalf("SetInputRects allocs %v", n)
67+ }
68+ })
69+}
70+
71+func TestNodeRectAndFrameSize(t *testing.T) {
72+ r := newRuntime()
73+ r.width, r.height = 400, 300
74+ var w, h float64
75+ r.Build(func(f *Frame) {
76+ w, h = f.Size()
77+ stack := f.Root().Stack()
78+ stack.Box(Key("a")).Rect(10, 20, 30, 40)
79+ stack.Box(Key("b")).Rect(50.5, 60.25, 70, 80)
80+ })
81+ if w != 400 || h != 300 {
82+ t.Fatalf("size %v x %v", w, h)
83+ }
84+ kids := r.output.Tree.Children[0].Children
85+ if len(kids) != 2 {
86+ t.Fatalf("children %d", len(kids))
87+ }
88+ 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}) {
89+ t.Fatalf("rects %+v %+v", kids[0].Border, kids[1].Border)
90+ }
91+ // Hit testing follows the rect geometry.
92+ if id := layout.Hit(r.output.Tree, 12, 22); id != kids[0].ID {
93+ t.Fatalf("hit %q want %q", id, kids[0].ID)
94+ }
95+}
96+
97+func TestNodeRectOutsideStackIsIgnored(t *testing.T) {
98+ r := newRuntime()
99+ r.width, r.height = 200, 100
100+ r.Build(func(f *Frame) {
101+ col := f.Root().Column()
102+ col.Box(Key("a"), Inline("height:10px")).Rect(50, 50, 5, 5)
103+ col.Box(Key("b"), Inline("height:10px"))
104+ })
105+ kids := r.output.Tree.Children[0].Children
106+ if kids[0].Border.X != 0 || kids[0].Border.H != 10 || kids[1].Border.Y != 10 {
107+ t.Fatalf("Rect changed normal flow: %+v %+v", kids[0].Border, kids[1].Border)
108+ }
109+}
110+
111+// The absolute geometry path adds no allocations over the same tree without it.
112+func TestNodeRectAddsNoAllocations(t *testing.T) {
113+ build := func(withRect bool) func() {
114+ r := newRuntime()
115+ r.width, r.height = 640, 480
116+ view := func(f *Frame) {
117+ st := f.Root().Stack()
118+ for i := 0; i < 32; i++ {
119+ b := st.Box(Key(strconv.Itoa(i)), Inline("background:#fff"))
120+ if withRect {
121+ b.Rect(float64(i), float64(i), 20, 20)
122+ }
123+ }
124+ }
125+ r.Build(view)
126+ return func() { r.Redraw(); r.Build(view) }
127+ }
128+ plain := testing.AllocsPerRun(20, build(false))
129+ rect := testing.AllocsPerRun(20, build(true))
130+ if rect > plain {
131+ t.Fatalf("Rect added allocations: %v vs %v", rect, plain)
132+ }
133+}
M internal/ui/interaction.go +8 -2
@@ -8,7 +8,6 @@ import (
88
99 "github.com/bnema/nefergui/internal/css"
1010 "github.com/bnema/nefergui/internal/edit"
11- "github.com/bnema/nefergui/internal/keyboard"
1211 "github.com/bnema/nefergui/internal/layout"
1312 "github.com/bnema/nefergui/internal/text"
1413 )
@@ -19,13 +18,20 @@ type platformInput struct {
1918 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize
2019 X, Y, DX, DY float64
2120 Width, Height, Scale float64
22- Key keyboard.Key
21+ Key keyEvent
2322 Button uint32 // evdev button code (BTN_LEFT is primary)
2423 Shift, Ctrl bool
2524 IME edit.IMEBatch
2625 Time time.Time // optional event timestamp; zero uses receipt time
2726 }
2827
28+// keyEvent is the key part of a platformInput.
29+type keyEvent struct {
30+ Name, Text string
31+ Pressed, Repeat bool
32+ Shift, Ctrl bool
33+}
34+
2935 const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT
3036
3137 type interactionState struct {
M internal/ui/interaction_test.go +10 -12
@@ -6,13 +6,11 @@ import (
66 "testing"
77
88 "github.com/bnema/nefergui/internal/css"
9- "github.com/bnema/nefergui/internal/keyboard"
10- "github.com/bnema/nefergui/internal/platform/wayland"
119 "github.com/bnema/nefergui/internal/text"
1210 )
1311
1412 func key(name string) platformInput {
15- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Pressed: true}}
13+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Pressed: true}}
1614 }
1715 func shell(r *runtime, activated *int) func(*Frame) {
1816 return func(f *Frame) {
@@ -67,7 +65,7 @@ func TestShellStyleAndFocus(t *testing.T) {
6765 if !r.state.focus.same(r.Target(3)) {
6866 t.Fatal("disabled included in tab order")
6967 }
70- r.route(platformInput{Kind: "key", Shift: true, Key: keyboard.Key{Name: "Tab", Pressed: true}})
68+ r.route(platformInput{Kind: "key", Shift: true, Key: keyEvent{Name: "Tab", Pressed: true}})
7169 r.Build(view)
7270 if !r.state.focus.same(r.Target(1)) {
7371 t.Fatal("reverse tab")
@@ -257,13 +255,13 @@ func TestCursorFollowsHoverAndCapture(t *testing.T) {
257255 }
258256 for _, c := range []struct {
259257 y float64
260- want uint32
261- }{{2, wayland.CursorPointer}, {22, wayland.CursorText}, {42, wayland.CursorNotAllowed}, {62, wayland.CursorDefault}} {
262- 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 {
263261 t.Errorf("y=%v shape %v, want %v", c.y, got, c.want)
264262 }
265263 }
266- if cursorShape(css.KeywordAuto) != cursorShape(css.KeywordDefault) {
264+ if cursorOf(css.KeywordAuto) != cursorOf(css.KeywordDefault) {
267265 t.Error("auto must map to the default shape")
268266 }
269267 // Press on the input, drag over the button: the input keeps its cursor.
@@ -361,7 +359,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
361359 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {
362360 t.Fatal("space press should only set active")
363361 }
364- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
362+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
365363 r.Build(view)
366364 if count != 2 || r.state.space != nil {
367365 t.Fatal("space release should activate once", count)
@@ -369,7 +367,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
369367 r.route(space)
370368 r.route(key("Escape"))
371369 r.Build(view)
372- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
370+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
373371 if r.Build(view) {
374372 t.Fatal("cancelled release redrew")
375373 }
@@ -379,7 +377,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
379377 r.route(space)
380378 r.route(platformInput{Kind: "focus-out"})
381379 r.Build(view)
382- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
380+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
383381 if r.Build(view) || count != 2 {
384382 t.Fatal("focus loss failed to cancel")
385383 }
@@ -388,7 +386,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
388386 r.route(space)
389387 r.route(key("Tab"))
390388 r.Build(view)
391- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
389+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
392390 if r.Build(view) || count != 2 {
393391 t.Fatal("focus change failed to cancel")
394392 }
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/paste_test.go +19 -24
@@ -3,29 +3,24 @@ package ui
33 import (
44 "context"
55 "errors"
6- "github.com/bnema/nefergui/internal/edit"
76 "testing"
8-)
97
10-type asyncClip struct {
11- contexts []context.Context
12- callbacks []func([]byte, error)
13-}
8+ "github.com/bnema/nefergui/internal/edit"
9+ "github.com/stretchr/testify/mock"
10+)
1411
15-func (*asyncClip) ReadText(int) ([]byte, error) { panic("sync clipboard read on loop") }
16-func (*asyncClip) WriteText([]byte) error { return nil }
17-func (c *asyncClip) ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error {
18- if limit != edit.MaxClipboardBytes {
19- panic("wrong limit")
20- }
21- c.contexts = append(c.contexts, ctx)
22- c.callbacks = append(c.callbacks, deliver)
23- return nil
24-}
2512 func TestAsyncPasteFrameAndCancellation(t *testing.T) {
2613 r := newRuntime()
27- c := &asyncClip{}
28- r.clipboard = c
14+ c := NewMockAsyncClipboard(t) // synchronous ReadText is not expected
15+ var contexts []context.Context
16+ var callbacks []func([]byte, error)
17+ c.EXPECT().ReadTextAsync(mock.Anything, edit.MaxClipboardBytes, mock.Anything).
18+ RunAndReturn(func(ctx context.Context, _ int, deliver func([]byte, error)) error {
19+ contexts = append(contexts, ctx)
20+ callbacks = append(callbacks, deliver)
21+ return nil
22+ })
23+ r.clipboard = asyncClipboard{c}
2924 name := ""
3025 var changed bool
3126 var diagnostics []string
@@ -45,28 +40,28 @@ func TestAsyncPasteFrameAndCancellation(t *testing.T) {
4540 }
4641 paste()
4742 paste()
48- if c.contexts[0].Err() != context.Canceled {
43+ if contexts[0].Err() != context.Canceled {
4944 t.Fatal("old read not cancelled")
5045 }
51- c.callbacks[0]([]byte("old"), nil)
52- c.callbacks[1]([]byte("Ada"), nil)
46+ callbacks[0]([]byte("old"), nil)
47+ callbacks[1]([]byte("Ada"), nil)
5348 if !r.Build(view) || !changed || name != "Ada" {
5449 t.Fatalf("paste: changed=%v value=%q", changed, name)
5550 }
5651 paste()
57- c.callbacks[2]([]byte{0xff}, nil)
52+ callbacks[2]([]byte{0xff}, nil)
5853 r.Build(view)
5954 if name != "Ada" || changed {
6055 t.Fatal("invalid UTF-8 accepted")
6156 }
6257 paste()
63- c.callbacks[3](make([]byte, edit.MaxClipboardBytes+1), nil)
58+ callbacks[3](make([]byte, edit.MaxClipboardBytes+1), nil)
6459 r.Build(view)
6560 if name != "Ada" || changed {
6661 t.Fatal("oversize accepted")
6762 }
6863 paste()
69- c.callbacks[4](nil, errors.New("transfer failed"))
64+ callbacks[4](nil, errors.New("transfer failed"))
7065 r.Build(view)
7166 if debugDiagnostics && len(diagnostics) != 1 {
7267 t.Fatalf("missing error diagnostic: %v", diagnostics)
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: in.Key, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
32-}
A internal/ui/race_off_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package ui
4+
5+const raceEnabled = false
A internal/ui/race_on_test.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package ui
4+
5+// raceEnabled: the race detector adds allocations, so alloc guards skip.
6+const raceEnabled = true
A internal/ui/renderer.go +424 -0
@@ -0,0 +1,424 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "math"
10+ "os"
11+ "slices"
12+
13+ "github.com/bnema/nefergui/internal/css"
14+ "github.com/bnema/nefergui/internal/edit"
15+ "github.com/bnema/nefergui/internal/layout"
16+ "github.com/bnema/nefergui/internal/presentation/session"
17+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
18+ "github.com/bnema/nefergui/internal/text"
19+)
20+
21+// Format is a DRM format and modifier the compositor accepts.
22+type Format struct {
23+ FourCC uint32
24+ Modifier uint64
25+}
26+
27+// Clipboard is the optional asynchronous clipboard; IME the optional input method.
28+type (
29+ Clipboard = edit.AsyncClipboard
30+ IME = edit.IME
31+)
32+
33+// Plane, Timeline and Retired are the transport-facing parts of Output.
34+type (
35+ Plane = session.Plane
36+ Timeline = session.Timeline
37+ Retired = session.Retired
38+)
39+
40+// Cursor is the pointer shape the surface wants.
41+type Cursor uint8
42+
43+const (
44+ CursorDefault Cursor = iota
45+ CursorPointer
46+ CursorText
47+ CursorNotAllowed
48+)
49+
50+// RendererConfig configures a Wayland-free Renderer.
51+type RendererConfig struct {
52+ MainDevice uint64 // dev_t from linux-dmabuf feedback main_device
53+ Formats []Format // compositor preference order, tranches flattened
54+ Transparent bool // ARGB8888 instead of XRGB8888
55+ Styles string // optional CSS file path
56+ Clipboard Clipboard
57+ IME IME
58+}
59+
60+// Input is one platform input event. Text is read only during the call.
61+type Input struct {
62+ Kind InputKind
63+ X, Y, DX, DY float64 // logical pixels; DX, DY are scroll deltas
64+ Button uint32 // evdev code
65+ Pressed bool
66+ Repeat bool
67+ Keysym uint32 // xkb keysym
68+ Text []byte
69+ Modifiers Modifiers
70+}
71+
72+// Output describes one frame to present, in physical pixels. Every file
73+// descriptor stays owned by the Renderer. Slices are reused and valid until the
74+// next Render.
75+type Output struct {
76+ Buffer uint64
77+ NewBuffer bool
78+ Retired []Retired
79+ Width, Height int32
80+ FourCC uint32
81+ Modifier uint64
82+ Planes [4]Plane
83+ PlaneCount int
84+ Acquire, Release Timeline
85+ NewTimelines bool
86+ AcquirePoint uint64
87+ ReleasePoint uint64
88+ ReleaseFD int
89+ Damage []Rect
90+ Cursor Cursor
91+ InputRects []Rect
92+ InputRectsChanged bool
93+}
94+
95+// target is the GPU seam of Renderer. *session.Target implements it.
96+type target interface {
97+ Resize(width, height int32)
98+ Draw(display []layout.Command, scale float64, out *session.Output) (bool, error)
99+ Released(buffer uint64) error
100+ Waiting() bool
101+ Close() error
102+}
103+
104+// Renderer renders views into DMA-BUF buffers for a transport it does not
105+// know. It owns no Wayland object and starts no goroutine. All methods except
106+// Wake run on one owner goroutine.
107+type Renderer struct {
108+ rt *runtime
109+ target target
110+ tout session.Output
111+ sized bool
112+ unsent bool // a built frame still waits for a free buffer
113+ w, h float64
114+ scale float64
115+ pw, ph int32
116+ closed bool
117+
118+ // Last input region handed out, to report only changes.
119+ region []Rect
120+ regionNil bool
121+ haveRegion bool
122+}
123+
124+// asyncClipboard lets an AsyncClipboard satisfy the editor's Clipboard port;
125+// synchronous reads are not supported, paste uses ReadTextAsync.
126+type asyncClipboard struct{ edit.AsyncClipboard }
127+
128+func (asyncClipboard) ReadText(int) ([]byte, error) {
129+ return nil, errors.New("nefergui: synchronous clipboard read not supported")
130+}
131+
132+// NewRenderer opens the GPU for cfg.MainDevice and loads fonts and styles. It
133+// allocates no images.
134+func NewRenderer(cfg RendererConfig) (*Renderer, error) {
135+ formats := make([]vkdevice.Format, len(cfg.Formats))
136+ for i, f := range cfg.Formats {
137+ formats[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
138+ }
139+ t, err := session.NewTarget(session.TargetConfig{MainDevice: cfg.MainDevice, Formats: formats, Transparent: cfg.Transparent})
140+ if err != nil {
141+ return nil, fmt.Errorf("nefergui: open renderer: %w", err)
142+ }
143+ r, err := newRenderer(cfg, t, text.SystemSource{})
144+ if err != nil {
145+ _ = t.Close()
146+ return nil, err
147+ }
148+ return r, nil
149+}
150+
151+func newRenderer(cfg RendererConfig, t target, fonts text.FontSource) (*Renderer, error) {
152+ rt := newRuntime()
153+ if cfg.Styles != "" {
154+ data, err := os.ReadFile(cfg.Styles)
155+ if err != nil {
156+ return nil, fmt.Errorf("nefergui: styles: %w", err)
157+ }
158+ rt.styles = css.Compile(css.UA(), css.Parse(string(data)))
159+ }
160+ catalog, err := text.Load(fonts)
161+ if err != nil {
162+ return nil, fmt.Errorf("nefergui: fonts: %w", err)
163+ }
164+ if len(catalog.Faces) == 0 {
165+ return nil, errors.New("nefergui: no usable fonts")
166+ }
167+ rt.setTextEngine(text.NewEngine(catalog))
168+ ctx, cancel := context.WithCancel(context.Background())
169+ rt.pasteCtx, rt.pasteCancel = ctx, cancel
170+ if cfg.Clipboard != nil {
171+ rt.clipboard = asyncClipboard{cfg.Clipboard}
172+ }
173+ if cfg.IME != nil {
174+ rt.ime = cfg.IME
175+ }
176+ return &Renderer{rt: rt, target: t, scale: 1}, nil
177+}
178+
179+// Wake returns a channel signaled when work arrives from another goroutine
180+// (for example a finished paste); call Render after receiving. Safe from any goroutine.
181+func (r *Renderer) Wake() <-chan struct{} { return r.rt.wake }
182+
183+// Pending reports that a built frame is waiting for the GPU (a buffer still
184+// being drawn, or a release wait not yet installed) rather than for the
185+// compositor. No event announces this: arm a short timer (for example 2 ms)
186+// and call Render again. It is false while every buffer belongs to the
187+// compositor; Released is the wake-up for that case.
188+func (r *Renderer) Pending() bool { return r.unsent && r.target.Waiting() }
189+
190+// Resize sets the logical surface size and scale. Invalid values are ignored.
191+func (r *Renderer) Resize(width, height int, scale float64) {
192+ if r.closed {
193+ return
194+ }
195+ w, h := float64(width), float64(height)
196+ if width <= 0 || height <= 0 || !(scale > 0) || math.IsInf(scale, 0) {
197+ return
198+ }
199+ pw, ph := math.Ceil(w*scale-1e-9), math.Ceil(h*scale-1e-9)
200+ if pw > math.MaxInt32 || ph > math.MaxInt32 {
201+ return
202+ }
203+ if r.sized && r.w == w && r.h == h && r.scale == scale {
204+ return
205+ }
206+ r.sized, r.w, r.h, r.scale = true, w, h, scale
207+ r.pw, r.ph = int32(pw), int32(ph)
208+ r.target.Resize(r.pw, r.ph)
209+ r.rt.route(platformInput{Kind: "resize", Width: w, Height: h, Scale: scale})
210+}
211+
212+// Invalidate requests a rebuild at the next Render.
213+func (r *Renderer) Invalidate() { r.rt.Redraw() }
214+
215+// Input routes one event and reports whether a redraw is needed.
216+func (r *Renderer) Input(ev *Input) bool {
217+ if ev == nil || r.closed {
218+ return false
219+ }
220+ switch ev.Kind {
221+ case InputReset:
222+ r.rt.route(platformInput{Kind: "leave"})
223+ r.rt.route(platformInput{Kind: "focus-out"})
224+ case InputPointerMotion:
225+ r.rt.route(platformInput{Kind: "motion", X: ev.X, Y: ev.Y})
226+ case InputPointerPress:
227+ r.rt.route(platformInput{Kind: "press", X: ev.X, Y: ev.Y, Button: ev.Button, Shift: ev.Modifiers&ModShift != 0})
228+ case InputPointerRelease:
229+ r.rt.route(platformInput{Kind: "release", X: ev.X, Y: ev.Y, Button: ev.Button})
230+ case InputPointerAxis:
231+ if ev.DX == 0 && ev.DY == 0 {
232+ return false
233+ }
234+ r.rt.route(platformInput{Kind: "axis", X: ev.X, Y: ev.Y, DX: ev.DX, DY: ev.DY})
235+ case InputPointerLeave:
236+ r.rt.route(platformInput{Kind: "leave"})
237+ case InputFocusIn:
238+ r.rt.route(platformInput{Kind: "focus-in"})
239+ case InputFocusOut:
240+ r.rt.route(platformInput{Kind: "focus-out"})
241+ case InputKey:
242+ shift, ctrl := ev.Modifiers&ModShift != 0, ev.Modifiers&ModCtrl != 0
243+ key := keyEvent{Name: keysymName(ev.Keysym), Pressed: ev.Pressed, Repeat: ev.Repeat, Shift: shift, Ctrl: ctrl}
244+ if len(ev.Text) > 0 {
245+ key.Text = string(ev.Text)
246+ }
247+ r.rt.route(platformInput{Kind: "key", Key: key, Shift: shift, Ctrl: ctrl})
248+ default:
249+ return false
250+ }
251+ r.rt.mu.Lock()
252+ need := r.rt.redraw || len(r.rt.pending) > 0
253+ r.rt.mu.Unlock()
254+ return need
255+}
256+
257+// Render builds the view and, when something changed and a buffer is free,
258+// draws it. It returns false without touching the GPU when nothing changed, no
259+// buffer is available yet (call it again after Released) or before the first
260+// Resize. When it returns true, out describes the frame to present.
261+func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (bool, error) {
262+ if r.closed {
263+ return false, errClosed
264+ }
265+ if out == nil || model == nil || view == nil {
266+ return false, errors.New("nefergui: nil output, model or view")
267+ }
268+ if !r.sized {
269+ return false, nil
270+ }
271+ if r.rt.Build(func(f *Frame) { view(f, model) }) {
272+ r.unsent = true
273+ }
274+ if !r.unsent {
275+ return false, nil
276+ }
277+ ok, err := r.target.Draw(r.rt.output.Display, r.scale, &r.tout)
278+ if err != nil || !ok {
279+ return false, err
280+ }
281+ r.unsent = false
282+ r.export(out)
283+ return true, nil
284+}
285+
286+// export copies the target's frame description into out and adds the parts
287+// derived from the UI: damage, cursor and the staged input region.
288+func (r *Renderer) export(out *Output) {
289+ t := &r.tout
290+ retired := append(out.Retired[:0], t.Retired...)
291+ damage := append(out.Damage[:0], Rect{Width: t.Width, Height: t.Height})
292+ rects := out.InputRects
293+ *out = Output{
294+ Buffer: t.Buffer, NewBuffer: t.NewBuffer, Retired: retired,
295+ Width: t.Width, Height: t.Height, FourCC: t.FourCC, Modifier: t.Modifier,
296+ Planes: t.Planes, PlaneCount: t.PlaneCount, Acquire: t.Acquire, Release: t.Release,
297+ NewTimelines: t.NewTimelines, AcquirePoint: t.AcquirePoint, ReleasePoint: t.ReleasePoint, ReleaseFD: t.ReleaseFD,
298+ Damage: damage, Cursor: cursorOf(r.rt.cursor()), InputRects: rects[:0],
299+ }
300+ rt := r.rt
301+ if !rt.inputStaged {
302+ return
303+ }
304+ rt.inputStaged = false
305+ // A region equal to the last reported one costs nothing.
306+ if r.haveRegion && r.regionNil == rt.inputNil && slices.Equal(r.region, rt.inputRects) {
307+ return
308+ }
309+ r.haveRegion, r.regionNil = true, rt.inputNil
310+ r.region = append(r.region[:0], rt.inputRects...)
311+ out.InputRectsChanged = true
312+ if rt.inputNil {
313+ out.InputRects = nil
314+ return
315+ }
316+ if out.InputRects == nil {
317+ out.InputRects = []Rect{}
318+ }
319+ out.InputRects = append(out.InputRects, rt.inputRects...)
320+}
321+
322+func cursorOf(k css.Keyword) Cursor {
323+ switch k {
324+ case css.KeywordPointer:
325+ return CursorPointer
326+ case css.KeywordText:
327+ return CursorText
328+ case css.KeywordNotAllowed:
329+ return CursorNotAllowed
330+ }
331+ return CursorDefault
332+}
333+
334+// Released must be called when the release eventfd of an Output buffer is
335+// readable. It lets the buffer be reused.
336+func (r *Renderer) Released(buffer uint64) error {
337+ if r.closed {
338+ return errClosed
339+ }
340+ return r.target.Released(buffer)
341+}
342+
343+var errClosed = errors.New("nefergui: renderer closed")
344+
345+// Close cancels pending pastes and frees the GPU resources. The Renderer is
346+// unusable afterwards.
347+func (r *Renderer) Close() error {
348+ if r.closed {
349+ return nil
350+ }
351+ r.closed = true
352+ if r.rt.pasteCancel != nil {
353+ r.rt.pasteCancel()
354+ }
355+ return r.target.Close()
356+}
357+
358+// xkb keysym values; internal/ui never imports an xkb library.
359+const (
360+ keysymSpace = 0x0020
361+ keysymBackspace = 0xff08
362+ keysymTab = 0xff09
363+ keysymReturn = 0xff0d
364+ keysymEscape = 0xff1b
365+ keysymHome = 0xff50
366+ keysymLeft = 0xff51
367+ keysymUp = 0xff52
368+ keysymRight = 0xff53
369+ keysymDown = 0xff54
370+ keysymPageUp = 0xff55
371+ keysymPageDown = 0xff56
372+ keysymEnd = 0xff57
373+ keysymKPEnter = 0xff8d
374+ keysymISOLeft = 0xfe20
375+ keysymDelete = 0xffff
376+)
377+
378+// keysymName maps a keysym to the name interaction.go dispatches on; other
379+// keysyms have no name because only their text matters.
380+func keysymName(sym uint32) string {
381+ switch sym {
382+ case keysymSpace:
383+ return "space"
384+ case keysymTab:
385+ return "tab"
386+ case keysymISOLeft:
387+ return "iso_left_tab"
388+ case keysymLeft:
389+ return "left"
390+ case keysymRight:
391+ return "right"
392+ case keysymUp:
393+ return "up"
394+ case keysymDown:
395+ return "down"
396+ case keysymHome:
397+ return "home"
398+ case keysymEnd:
399+ return "end"
400+ case keysymPageUp:
401+ return "page_up"
402+ case keysymPageDown:
403+ return "page_down"
404+ case keysymBackspace:
405+ return "backspace"
406+ case keysymDelete:
407+ return "delete"
408+ case 'a', 'A':
409+ return "a"
410+ case 'c', 'C':
411+ return "c"
412+ case 'x', 'X':
413+ return "x"
414+ case 'v', 'V':
415+ return "v"
416+ case keysymReturn:
417+ return "return"
418+ case keysymKPEnter:
419+ return "kp_enter"
420+ case keysymEscape:
421+ return "escape"
422+ }
423+ return ""
424+}
A internal/ui/renderer_gpu_test.go +273 -0
@@ -0,0 +1,273 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "cmp"
7+ "os"
8+ "slices"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/nefergui/internal/presentation/session"
13+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
14+ "github.com/bnema/nefergui/internal/text"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// gpuRenderer builds a real Renderer on the render node named by
19+// NEFERGUI_RENDER_NODE (skips without it) with readback enabled.
20+func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
21+ t.Helper()
22+ path := os.Getenv("NEFERGUI_RENDER_NODE")
23+ if path == "" {
24+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
25+ }
26+ var stat unix.Stat_t
27+ if err := unix.Stat(path, &stat); err != nil {
28+ t.Fatal(err)
29+ }
30+ dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev)))
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ mods, err := dev.ExportableModifiers()
35+ dev.Close()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ var formats []Format
40+ for m := range mods {
41+ formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
42+ }
43+ slices.SortFunc(formats, func(a, b Format) int { return cmp.Compare(a.Modifier, b.Modifier) })
44+ vf := make([]vkdevice.Format, len(formats))
45+ for i, f := range formats {
46+ vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
47+ }
48+ tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf})
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ tg.EnableReadback()
53+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
54+ if err != nil {
55+ _ = tg.Close()
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { _ = r.Close() })
59+ return r, tg
60+}
61+
62+func readable(t testing.TB, fd int, timeout time.Duration) bool {
63+ t.Helper()
64+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
65+ n, err := unix.Poll(fds, int(timeout.Milliseconds()))
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ return n > 0
70+}
71+
72+// signalRelease plays the compositor: it signals the buffer's release point.
73+func signalRelease(t testing.TB, tg *session.Target, out *Output) {
74+ t.Helper()
75+ slot := tg.Slots[out.Buffer]
76+ if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
77+ t.Fatal(err)
78+ }
79+}
80+
81+func TestRendererGPUPixelAndRelease(t *testing.T) {
82+ r, tg := gpuRenderer(t)
83+ var out Output
84+ var m struct{}
85+ view := func(f *Frame, _ *struct{}) {
86+ f.Root().Box(Inline("width:50px;height:50px;background:#336699"))
87+ }
88+ r.Resize(100, 80, 1)
89+ ok, err := r.Render(&out, &m, view)
90+ if err != nil || !ok {
91+ t.Fatalf("render: ok=%v err=%v", ok, err)
92+ }
93+ if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 ||
94+ out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 ||
95+ out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint {
96+ t.Fatalf("output: %+v", out)
97+ }
98+ img, err := tg.Readback(out.Buffer)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 }
103+ px := img.NRGBAAt(10, 10)
104+ if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) {
105+ t.Fatalf("pixel inside box = %+v", px)
106+ }
107+ if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 {
108+ t.Fatalf("pixel outside box = %+v", px)
109+ }
110+ // Nothing is released until the compositor signals the point.
111+ if readable(t, out.ReleaseFD, 20*time.Millisecond) {
112+ t.Fatal("release eventfd readable before the point signaled")
113+ }
114+ signalRelease(t, tg, &out)
115+ if !readable(t, out.ReleaseFD, time.Second) {
116+ t.Fatal("release eventfd not readable after the release point signaled")
117+ }
118+ if err = r.Released(out.Buffer); err != nil {
119+ t.Fatal(err)
120+ }
121+ if readable(t, out.ReleaseFD, 0) {
122+ t.Fatal("release eventfd still readable after Released")
123+ }
124+ // The same buffer comes back with stable descriptors and no re-import.
125+ first := out.Buffer
126+ r.Invalidate()
127+ seen := map[uint64]bool{first: true}
128+ for i := 0; i < 3 && len(seen) < 3; i++ {
129+ r.Invalidate()
130+ ok, err = r.Render(&out, &m, view)
131+ if err != nil || !ok {
132+ t.Fatalf("render %d: ok=%v err=%v", i, ok, err)
133+ }
134+ seen[out.Buffer] = true
135+ if !out.NewBuffer && out.NewTimelines {
136+ t.Fatal("timelines announced for a known buffer")
137+ }
138+ }
139+ if len(seen) != 3 {
140+ t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen)
141+ }
142+ // All three are compositor owned: no buffer is free, Render must not draw.
143+ r.Invalidate()
144+ if ok, err = r.Render(&out, &m, view); ok || err != nil {
145+ t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err)
146+ }
147+}
148+
149+func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
150+ r, tg := gpuRenderer(t)
151+ var out Output
152+ var m struct{}
153+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
154+ r.Resize(64, 64, 1)
155+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
156+ t.Fatalf("ok=%v err=%v", ok, err)
157+ }
158+ old := out.Buffer
159+ signalRelease(t, tg, &out)
160+ if err := r.Released(old); err != nil {
161+ t.Fatal(err)
162+ }
163+ // Retirement waits for the old frame's GPU work; a late one is reported by
164+ // a later Render, so let it finish to keep the test deterministic.
165+ if err := tg.Slots[old].Frame.Wait(); err != nil {
166+ t.Fatal(err)
167+ }
168+ r.Resize(96, 64, 1)
169+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
170+ t.Fatalf("after resize: ok=%v err=%v", ok, err)
171+ }
172+ if out.Width != 96 || !out.NewBuffer || out.Buffer == old {
173+ t.Fatalf("resize output: %+v", out)
174+ }
175+ found := false
176+ for _, rt := range out.Retired {
177+ found = found || rt.Buffer == old
178+ }
179+ if !found {
180+ t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired)
181+ }
182+}
183+
184+// allocBaselineRenderer is the measured allocation count of one steady
185+// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the
186+// target is 0, the remainder is frame build, layout and prepare.
187+const allocBaselineRenderer = 244
188+
189+// TestAllocRendererSteadyFrame measures Render with a view whose number
190+// changes on every frame and an unchanged structure. It runs on the real GPU
191+// path because testify mocks would allocate.
192+func TestAllocRendererSteadyFrame(t *testing.T) {
193+ if raceEnabled {
194+ t.Skip("race instrumentation changes allocation counts")
195+ }
196+ r, tg := gpuRenderer(t)
197+ var out Output
198+ n := 0
199+ view := func(f *Frame, n *int) {
200+ root := f.Root()
201+ root.Heading("Counter")
202+ root.TextInt("Value: ", int64(*n))
203+ root.Button("Go", Key("go"))
204+ }
205+ r.Resize(160, 120, 1)
206+ step := func() {
207+ n++
208+ r.Invalidate()
209+ ok, err := r.Render(&out, &n, view)
210+ for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
211+ time.Sleep(200 * time.Microsecond)
212+ ok, err = r.Render(&out, &n, view)
213+ }
214+ if err != nil || !ok {
215+ t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
216+ }
217+ slot := tg.Slots[out.Buffer]
218+ if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
219+ t.Fatal(err)
220+ }
221+ if err = r.Released(out.Buffer); err != nil {
222+ t.Fatal(err)
223+ }
224+ }
225+ for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
226+ step()
227+ }
228+ got := testing.AllocsPerRun(50, step)
229+ t.Logf("allocs per steady Render+Released cycle: %v", got)
230+ if got > allocBaselineRenderer {
231+ t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
232+ }
233+}
234+
235+// A readable eventfd whose point is not signaled (a stale notification) must be
236+// drained without spinning, and the live registration must still work.
237+func TestRendererGPUStaleReleaseNotification(t *testing.T) {
238+ r, tg := gpuRenderer(t)
239+ var out Output
240+ var m struct{}
241+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
242+ r.Resize(64, 64, 1)
243+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
244+ t.Fatalf("ok=%v err=%v", ok, err)
245+ }
246+ one := [8]byte{1}
247+ if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
248+ t.Fatal(err)
249+ }
250+ if !readable(t, out.ReleaseFD, time.Second) {
251+ t.Fatal("test setup: eventfd not readable")
252+ }
253+ if err := r.Released(out.Buffer); err != nil {
254+ t.Fatal(err)
255+ }
256+ if readable(t, out.ReleaseFD, 0) {
257+ t.Fatal("stale notification left the eventfd readable: the caller would spin")
258+ }
259+ if tg.Slots[out.Buffer].Buffer.State == "available" {
260+ t.Fatal("buffer reused before the compositor released it")
261+ }
262+ signalRelease(t, tg, &out)
263+ if !readable(t, out.ReleaseFD, time.Second) {
264+ t.Fatal("live registration lost after a stale notification")
265+ }
266+ if err := r.Released(out.Buffer); err != nil {
267+ t.Fatal(err)
268+ }
269+ // Released for an unknown or idle buffer is a no-op that still drains.
270+ if err := r.Released(999); err != nil {
271+ t.Fatal(err)
272+ }
273+}
A internal/ui/renderer_test.go +277 -0
@@ -0,0 +1,277 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/nefergui/internal/layout"
10+ "github.com/bnema/nefergui/internal/presentation/session"
11+ "github.com/bnema/nefergui/internal/text"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+type testModel struct {
16+ clicks int
17+ rects []Rect
18+}
19+
20+func testView(f *Frame, m *testModel) {
21+ root := f.Root()
22+ if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
23+ m.clicks++
24+ }
25+ if m.rects != nil {
26+ _ = f.SetInputRects(m.rects)
27+ }
28+}
29+
30+func newTestRenderer(t *testing.T) (*Renderer, *mocktarget) {
31+ t.Helper()
32+ tg := newMocktarget(t)
33+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ return r, tg
38+}
39+
40+func drawOK(tg *mocktarget, fill func(*session.Output)) *mocktarget_Draw_Call {
41+ return tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).
42+ RunAndReturn(func(_ []layout.Command, _ float64, out *session.Output) (bool, error) {
43+ *out = session.Output{Buffer: 1, Width: 100, Height: 50}
44+ if fill != nil {
45+ fill(out)
46+ }
47+ return true, nil
48+ })
49+}
50+
51+func TestKeysymNames(t *testing.T) {
52+ for sym, want := range map[uint32]string{
53+ 0x20: "space", 0xff09: "tab", 0xfe20: "iso_left_tab", 0xff51: "left", 0xff53: "right",
54+ 0xff52: "up", 0xff54: "down", 0xff50: "home", 0xff57: "end", 0xff55: "page_up",
55+ 0xff56: "page_down", 0xff08: "backspace", 0xffff: "delete", 'a': "a", 'A': "a",
56+ 'c': "c", 'x': "x", 'v': "v", 0xff0d: "return", 0xff8d: "kp_enter", 0xff1b: "escape",
57+ 'z': "", 0xffe1: "",
58+ } {
59+ if got := keysymName(sym); got != want {
60+ t.Errorf("keysym %#x: got %q want %q", sym, got, want)
61+ }
62+ }
63+}
64+
65+func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
66+ r, tg := newTestRenderer(t)
67+ var m testModel
68+ var out Output
69+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
70+ t.Fatalf("before Resize: ok=%v err=%v", ok, err)
71+ }
72+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
73+ r.Resize(100, 50, 2)
74+ drawOK(tg, nil).Once()
75+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
76+ t.Fatalf("first render: ok=%v err=%v", ok, err)
77+ }
78+ if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
79+ t.Fatalf("output: %+v", out)
80+ }
81+ // Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
82+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
83+ t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
84+ }
85+}
86+
87+func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
88+ r, tg := newTestRenderer(t)
89+ var m testModel
90+ var out Output
91+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
92+ r.Resize(100, 50, 1)
93+ tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
94+ tg.EXPECT().Waiting().Return(true).Once()
95+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
96+ t.Fatalf("no buffer: ok=%v err=%v", ok, err)
97+ }
98+ if !r.Pending() {
99+ t.Fatal("a built frame blocked on the GPU must report Pending")
100+ }
101+ drawOK(tg, nil).Once()
102+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
103+ t.Fatalf("retry: ok=%v err=%v", ok, err)
104+ }
105+}
106+
107+func TestRendererDrawError(t *testing.T) {
108+ r, tg := newTestRenderer(t)
109+ var m testModel
110+ var out Output
111+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
112+ r.Resize(100, 50, 1)
113+ boom := errors.New("boom")
114+ tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, boom).Once()
115+ if ok, err := r.Render(&out, &m, testView); ok || !errors.Is(err, boom) {
116+ t.Fatalf("ok=%v err=%v", ok, err)
117+ }
118+}
119+
120+func TestRendererResizeReachesTargetInPhysicalPixels(t *testing.T) {
121+ r, tg := newTestRenderer(t)
122+ tg.EXPECT().Resize(int32(150), int32(75)).Return().Once()
123+ r.Resize(100, 50, 1.5)
124+ r.Resize(100, 50, 1.5) // unchanged: no second call
125+ r.Resize(0, 50, 1) // invalid: ignored
126+ r.Resize(10, 10, 0) // invalid: ignored
127+}
128+
129+func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
130+ r, tg := newTestRenderer(t)
131+ var m testModel
132+ var out Output
133+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
134+ r.Resize(200, 100, 1)
135+ drawOK(tg, nil).Maybe()
136+ for i := 0; i < 3; i++ {
137+ if _, err := r.Render(&out, &m, testView); err != nil {
138+ t.Fatal(err)
139+ }
140+ }
141+ if r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) != true {
142+ t.Fatal("hover over a button needs a redraw")
143+ }
144+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
145+ if !r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) {
146+ t.Fatal("release did not request redraw")
147+ }
148+ for i := 0; i < 3; i++ {
149+ if _, err := r.Render(&out, &m, testView); err != nil {
150+ t.Fatal(err)
151+ }
152+ }
153+ if m.clicks != 1 {
154+ t.Fatalf("clicks=%d", m.clicks)
155+ }
156+ // Keyboard: Tab focuses, Return activates, text comes from Text.
157+ r.Input(&Input{Kind: InputPointerLeave})
158+ r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true})
159+ r.Input(&Input{Kind: InputKey, Keysym: 0xff0d, Pressed: true})
160+ for i := 0; i < 3; i++ {
161+ if _, err := r.Render(&out, &m, testView); err != nil {
162+ t.Fatal(err)
163+ }
164+ }
165+ if m.clicks != 2 {
166+ t.Fatalf("Return did not activate the focused button: clicks=%d", m.clicks)
167+ }
168+ if r.Input(&Input{Kind: 99}) || r.Input(nil) {
169+ t.Fatal("unknown input requested a redraw")
170+ }
171+ if r.Input(&Input{Kind: InputPointerAxis}) {
172+ t.Fatal("zero scroll requested a redraw")
173+ }
174+}
175+
176+func TestRendererTextInputUsesTextBytes(t *testing.T) {
177+ r, tg := newTestRenderer(t)
178+ value := ""
179+ var out Output
180+ view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
181+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
182+ r.Resize(200, 100, 1)
183+ drawOK(tg, nil).Maybe()
184+ settle := func() {
185+ for i := 0; i < 3; i++ {
186+ if _, err := r.Render(&out, &value, view); err != nil {
187+ t.Fatal(err)
188+ }
189+ }
190+ }
191+ settle()
192+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
193+ r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
194+ settle()
195+ text := []byte("é")
196+ r.Input(&Input{Kind: InputKey, Keysym: 0xe9, Pressed: true, Text: text})
197+ text[0] = 0 // the caller may reuse the buffer after the call
198+ settle()
199+ if value != "é" {
200+ t.Fatalf("value=%q", value)
201+ }
202+}
203+
204+func TestRendererOutputCarriesCursorAndInputRects(t *testing.T) {
205+ r, tg := newTestRenderer(t)
206+ m := testModel{rects: []Rect{{1, 2, 30, 20}}}
207+ var out Output
208+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
209+ r.Resize(200, 100, 1)
210+ drawOK(tg, nil).Maybe()
211+ r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) // over the button: pointer cursor
212+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
213+ t.Fatalf("ok=%v err=%v", ok, err)
214+ }
215+ if out.Cursor != CursorPointer || !out.InputRectsChanged || len(out.InputRects) != 1 || out.InputRects[0] != (Rect{1, 2, 30, 20}) {
216+ t.Fatalf("output: cursor=%v changed=%v rects=%v", out.Cursor, out.InputRectsChanged, out.InputRects)
217+ }
218+ // An unchanged region is not reported again.
219+ r.Invalidate()
220+ ok, err := r.Render(&out, &m, testView)
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ if ok && out.InputRectsChanged && len(out.InputRects) != 0 {
225+ t.Fatalf("region reported twice: %+v", out)
226+ }
227+ // nil restores the whole surface: changed with no rects.
228+ m.rects = nil
229+ var nilView = func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects(nil) }
230+ r.Invalidate()
231+ if ok, err = r.Render(&out, &m, nilView); err != nil || !ok || !out.InputRectsChanged || out.InputRects != nil {
232+ t.Fatalf("nil region: ok=%v err=%v %+v", ok, err, out)
233+ }
234+}
235+
236+func TestRendererForwardsReleasedAndClose(t *testing.T) {
237+ r, tg := newTestRenderer(t)
238+ tg.EXPECT().Released(uint64(3)).Return(nil).Once()
239+ if err := r.Released(3); err != nil {
240+ t.Fatal(err)
241+ }
242+ tg.EXPECT().Close().Return(nil).Once()
243+ if err := r.Close(); err != nil {
244+ t.Fatal(err)
245+ }
246+ // After Close nothing reaches the target (the mock has no expectation).
247+ r.Resize(10, 10, 1)
248+ if r.Input(&Input{Kind: InputPointerMotion}) {
249+ t.Fatal("Input after Close requested a redraw")
250+ }
251+ if err := r.Released(3); err == nil {
252+ t.Fatal("Released after Close succeeded")
253+ }
254+ if err := r.Close(); err != nil { // second Close is a no-op
255+ t.Fatal(err)
256+ }
257+ var out Output
258+ var m testModel
259+ if _, err := r.Render(&out, &m, testView); err == nil {
260+ t.Fatal("Render after Close succeeded")
261+ }
262+}
263+
264+func TestTextIntAndMasked(t *testing.T) {
265+ r := newRuntime()
266+ r.Build(func(f *Frame) {
267+ root := f.Root()
268+ root.TextInt("Count: ", -42)
269+ root.Masked(3)
270+ root.Masked(0)
271+ root.Masked(300)
272+ })
273+ c := r.committed.children
274+ if len(c) != 4 || c[0].text != "Count: -42" || c[1].text != "•••" || c[2].text != "" || len([]rune(c[3].text)) != 300 {
275+ t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
276+ }
277+}
M internal/ui/runtime.go +0 -254
@@ -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"
@@ -52,9 +43,7 @@ type runtime struct {
5243 pasteID uint64
5344 ime edit.IME
5445 redraw bool
55- consumed bool // the last committed build handled input events
5646 inputRects []Rect
57- inputOut []wayland.Rect
5847 inputNil, inputStaged bool
5948 }
6049
@@ -186,7 +175,6 @@ func (r *runtime) Build(view func(*Frame)) bool {
186175 if len(events) > 0 {
187176 r.redraw = true
188177 }
189- r.consumed = len(events) > 0
190178 // Release every losing editor before enabling the winner: the IME port
191179 // represents one seat, not one port per editor. Never depend on map order.
192180 for id, editor := range r.edits {
@@ -247,13 +235,6 @@ func (r *runtime) Build(view func(*Frame)) bool {
247235 return true
248236 }
249237
250-// consumedInput reports whether the last committed build handled input events.
251-func (r *runtime) consumedInput() bool {
252- r.mu.Lock()
253- defer r.mu.Unlock()
254- return r.consumed
255-}
256-
257238 // Wait blocks until input/redraw is available or the context is cancelled.
258239 func (r *runtime) Wait(ctx context.Context) error {
259240 select {
@@ -263,238 +244,3 @@ func (r *runtime) Wait(ctx context.Context) error {
263244 return nil
264245 }
265246 }
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 }) }
A internal/ui/target_mock_test.go +304 -0
@@ -0,0 +1,304 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package ui
6+
7+import (
8+ "github.com/bnema/nefergui/internal/layout"
9+ "github.com/bnema/nefergui/internal/presentation/session"
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocktarget creates a new instance of mocktarget. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocktarget(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocktarget {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocktarget{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocktarget is an autogenerated mock type for the target type
37+type mocktarget struct {
38+ mock.Mock
39+}
40+
41+type mocktarget_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocktarget) EXPECT() *mocktarget_Expecter {
46+ return &mocktarget_Expecter{mock: &_m.Mock}
47+}
48+
49+// Close provides a mock function for the type mocktarget
50+func (_mock *mocktarget) Close() error {
51+ ret := _mock.Called()
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Close")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func() error); ok {
59+ r0 = returnFunc()
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mocktarget_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
67+type mocktarget_Close_Call struct {
68+ *mock.Call
69+}
70+
71+// Close is a helper method to define mock.On call
72+func (_e *mocktarget_Expecter) Close() *mocktarget_Close_Call {
73+ return &mocktarget_Close_Call{Call: _e.mock.On("Close")}
74+}
75+
76+func (_c *mocktarget_Close_Call) Run(run func()) *mocktarget_Close_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ run()
79+ })
80+ return _c
81+}
82+
83+func (_c *mocktarget_Close_Call) Return(err error) *mocktarget_Close_Call {
84+ _c.Call.Return(err)
85+ return _c
86+}
87+
88+func (_c *mocktarget_Close_Call) RunAndReturn(run func() error) *mocktarget_Close_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// Draw provides a mock function for the type mocktarget
94+func (_mock *mocktarget) Draw(display []layout.Command, scale float64, out *session.Output) (bool, error) {
95+ ret := _mock.Called(display, scale, out)
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for Draw")
99+ }
100+
101+ var r0 bool
102+ var r1 error
103+ if returnFunc, ok := ret.Get(0).(func([]layout.Command, float64, *session.Output) (bool, error)); ok {
104+ return returnFunc(display, scale, out)
105+ }
106+ if returnFunc, ok := ret.Get(0).(func([]layout.Command, float64, *session.Output) bool); ok {
107+ r0 = returnFunc(display, scale, out)
108+ } else {
109+ r0 = ret.Get(0).(bool)
110+ }
111+ if returnFunc, ok := ret.Get(1).(func([]layout.Command, float64, *session.Output) error); ok {
112+ r1 = returnFunc(display, scale, out)
113+ } else {
114+ r1 = ret.Error(1)
115+ }
116+ return r0, r1
117+}
118+
119+// mocktarget_Draw_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Draw'
120+type mocktarget_Draw_Call struct {
121+ *mock.Call
122+}
123+
124+// Draw is a helper method to define mock.On call
125+// - display []layout.Command
126+// - scale float64
127+// - out *session.Output
128+func (_e *mocktarget_Expecter) Draw(display any, scale any, out any) *mocktarget_Draw_Call {
129+ return &mocktarget_Draw_Call{Call: _e.mock.On("Draw", display, scale, out)}
130+}
131+
132+func (_c *mocktarget_Draw_Call) Run(run func(display []layout.Command, scale float64, out *session.Output)) *mocktarget_Draw_Call {
133+ _c.Call.Run(func(args mock.Arguments) {
134+ var arg0 []layout.Command
135+ if args[0] != nil {
136+ arg0 = args[0].([]layout.Command)
137+ }
138+ var arg1 float64
139+ if args[1] != nil {
140+ arg1 = args[1].(float64)
141+ }
142+ var arg2 *session.Output
143+ if args[2] != nil {
144+ arg2 = args[2].(*session.Output)
145+ }
146+ run(
147+ arg0,
148+ arg1,
149+ arg2,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *mocktarget_Draw_Call) Return(b bool, err error) *mocktarget_Draw_Call {
156+ _c.Call.Return(b, err)
157+ return _c
158+}
159+
160+func (_c *mocktarget_Draw_Call) RunAndReturn(run func(display []layout.Command, scale float64, out *session.Output) (bool, error)) *mocktarget_Draw_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Released provides a mock function for the type mocktarget
166+func (_mock *mocktarget) Released(buffer uint64) error {
167+ ret := _mock.Called(buffer)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Released")
171+ }
172+
173+ var r0 error
174+ if returnFunc, ok := ret.Get(0).(func(uint64) error); ok {
175+ r0 = returnFunc(buffer)
176+ } else {
177+ r0 = ret.Error(0)
178+ }
179+ return r0
180+}
181+
182+// mocktarget_Released_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Released'
183+type mocktarget_Released_Call struct {
184+ *mock.Call
185+}
186+
187+// Released is a helper method to define mock.On call
188+// - buffer uint64
189+func (_e *mocktarget_Expecter) Released(buffer any) *mocktarget_Released_Call {
190+ return &mocktarget_Released_Call{Call: _e.mock.On("Released", buffer)}
191+}
192+
193+func (_c *mocktarget_Released_Call) Run(run func(buffer uint64)) *mocktarget_Released_Call {
194+ _c.Call.Run(func(args mock.Arguments) {
195+ var arg0 uint64
196+ if args[0] != nil {
197+ arg0 = args[0].(uint64)
198+ }
199+ run(
200+ arg0,
201+ )
202+ })
203+ return _c
204+}
205+
206+func (_c *mocktarget_Released_Call) Return(err error) *mocktarget_Released_Call {
207+ _c.Call.Return(err)
208+ return _c
209+}
210+
211+func (_c *mocktarget_Released_Call) RunAndReturn(run func(buffer uint64) error) *mocktarget_Released_Call {
212+ _c.Call.Return(run)
213+ return _c
214+}
215+
216+// Resize provides a mock function for the type mocktarget
217+func (_mock *mocktarget) Resize(width int32, height int32) {
218+ _mock.Called(width, height)
219+ return
220+}
221+
222+// mocktarget_Resize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resize'
223+type mocktarget_Resize_Call struct {
224+ *mock.Call
225+}
226+
227+// Resize is a helper method to define mock.On call
228+// - width int32
229+// - height int32
230+func (_e *mocktarget_Expecter) Resize(width any, height any) *mocktarget_Resize_Call {
231+ return &mocktarget_Resize_Call{Call: _e.mock.On("Resize", width, height)}
232+}
233+
234+func (_c *mocktarget_Resize_Call) Run(run func(width int32, height int32)) *mocktarget_Resize_Call {
235+ _c.Call.Run(func(args mock.Arguments) {
236+ var arg0 int32
237+ if args[0] != nil {
238+ arg0 = args[0].(int32)
239+ }
240+ var arg1 int32
241+ if args[1] != nil {
242+ arg1 = args[1].(int32)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ )
248+ })
249+ return _c
250+}
251+
252+func (_c *mocktarget_Resize_Call) Return() *mocktarget_Resize_Call {
253+ _c.Call.Return()
254+ return _c
255+}
256+
257+func (_c *mocktarget_Resize_Call) RunAndReturn(run func(width int32, height int32)) *mocktarget_Resize_Call {
258+ _c.Run(run)
259+ return _c
260+}
261+
262+// Waiting provides a mock function for the type mocktarget
263+func (_mock *mocktarget) Waiting() bool {
264+ ret := _mock.Called()
265+
266+ if len(ret) == 0 {
267+ panic("no return value specified for Waiting")
268+ }
269+
270+ var r0 bool
271+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
272+ r0 = returnFunc()
273+ } else {
274+ r0 = ret.Get(0).(bool)
275+ }
276+ return r0
277+}
278+
279+// mocktarget_Waiting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Waiting'
280+type mocktarget_Waiting_Call struct {
281+ *mock.Call
282+}
283+
284+// Waiting is a helper method to define mock.On call
285+func (_e *mocktarget_Expecter) Waiting() *mocktarget_Waiting_Call {
286+ return &mocktarget_Waiting_Call{Call: _e.mock.On("Waiting")}
287+}
288+
289+func (_c *mocktarget_Waiting_Call) Run(run func()) *mocktarget_Waiting_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ run()
292+ })
293+ return _c
294+}
295+
296+func (_c *mocktarget_Waiting_Call) Return(b bool) *mocktarget_Waiting_Call {
297+ _c.Call.Return(b)
298+ return _c
299+}
300+
301+func (_c *mocktarget_Waiting_Call) RunAndReturn(run func() bool) *mocktarget_Waiting_Call {
302+ _c.Call.Return(run)
303+ return _c
304+}
R examples/demo/app.css → internal/ui/testdata/demo.css +0 -0 renamed, 100% similar

renamed, 100% similar

M internal/ui/tree.go +1 -1
@@ -1,5 +1,5 @@
11 // Package ui owns the immediate-mode frame tree, controls, editors, input
2-// routing and the Wayland run loop behind the public nefergui facade.
2+// routing and the Renderer behind the public nefergui facade.
33 package ui
44
55 import (
M nefergui.go +21 -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
@@ -144,61 +111,6 @@ const (
144111
145112 ModShift = ui.ModShift
146113 ModCtrl = ui.ModCtrl
114+ ModAlt = ui.ModAlt
115+ ModSuper = ui.ModSuper
147116 )
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) }
A renderer.go +63 -0
@@ -0,0 +1,63 @@
1+//go:build linux
2+
3+package nefergui
4+
5+import "github.com/bnema/nefergui/internal/ui"
6+
7+// Renderer renders views into DMA-BUF buffers for a Wayland client the
8+// application owns. It imports no Wayland library, starts no goroutine and is
9+// used from one owner goroutine. Typical loop: forward Wayland events with
10+// Input and Resize, call Render when a redraw is needed, import and present
11+// the Output buffer, and call Released when its ReleaseFD becomes readable.
12+//
13+// Pending() reports a built frame waiting for the GPU rather than the
14+// compositor; arm a short timer (about 2 ms) and call Render again.
15+//
16+// Render is generic over the model type, so it is a generic method:
17+//
18+// ok, err := r.Render(&out, &model, view)
19+type Renderer = ui.Renderer
20+
21+// RendererConfig configures NewRenderer. MainDevice and Formats come from the
22+// compositor's linux-dmabuf feedback.
23+type RendererConfig = ui.RendererConfig
24+
25+// Format is a DRM format and modifier the compositor accepts.
26+type Format = ui.Format
27+
28+// Input is one platform input event; Text is valid only during Renderer.Input.
29+type Input = ui.Input
30+
31+// Output describes one frame to present, in physical pixels. All file
32+// descriptors stay owned by the Renderer: duplicate them before handing them to
33+// a transport that consumes descriptors. Slices are valid until the next Render.
34+type Output = ui.Output
35+
36+// Plane, Timeline and Retired are parts of Output. A Timeline FD is valid only
37+// when Output.NewTimelines is set. The client must stop watching and destroy
38+// each Retired buffer before the next Render.
39+type (
40+ Plane = ui.Plane
41+ Timeline = ui.Timeline
42+ Retired = ui.Retired
43+)
44+
45+// Clipboard is an optional asynchronous clipboard; IME an optional input method.
46+type (
47+ Clipboard = ui.Clipboard
48+ IME = ui.IME
49+)
50+
51+// Cursor is the pointer shape the surface wants.
52+type Cursor = ui.Cursor
53+
54+const (
55+ CursorDefault = ui.CursorDefault
56+ CursorPointer = ui.CursorPointer
57+ CursorText = ui.CursorText
58+ CursorNotAllowed = ui.CursorNotAllowed
59+)
60+
61+// NewRenderer opens the GPU named by cfg.MainDevice and loads fonts and
62+// styles. It allocates no images.
63+func NewRenderer(cfg RendererConfig) (*Renderer, error) { return ui.NewRenderer(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