Merge pull request #6 from bnema/refactor/wayland-free
refactor!: make NeferGUI a Wayland-free graphics library
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 @@ | |||
| 1 | 1 | # NeferGUI | |
| 2 | 2 | ||
| 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. | |
| 4 | 6 | ||
| 5 | 7 | > [!WARNING] | |
| 6 | 8 | > **Early development.** No release yet. Expect bugs and breaking changes. Platform accessibility is not connected. | |
| 7 | 9 | ||
| 8 | 10 | ## A small example | |
| 9 | 11 | ||
| 12 | + | The view is plain Go. The model type is yours. | |
| 13 | + | ||
| 10 | 14 | ```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++ | |
| 34 | 22 | } | |
| 23 | + | root.Text(fmt.Sprintf("Clicked %d times", m.Count)) | |
| 35 | 24 | } | |
| 36 | 25 | ``` | |
| 37 | 26 | ||
| 38 | - | Default styles work without a stylesheet; `nefergui.Styles("app.css")` loads your own CSS. | |
| 27 | + | The client loop, on one owner goroutine: | |
| 39 | 28 | ||
| 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 | + | ``` | |
| 41 | 41 | ||
| 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. | |
| 43 | 43 | ||
| 44 | - | ```sh | |
| 45 | - | go get github.com/bnema/nefergui | |
| 46 | - | ``` | |
| 44 | + | ## Requirements | |
| 47 | 45 | ||
| 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. | |
| 49 | 47 | ||
| 50 | 48 | ```sh | |
| 51 | - | CGO_ENABLED=0 go run ./examples/demo | |
| 49 | + | go get github.com/bnema/nefergui | |
| 52 | 50 | ``` | |
| 53 | 51 | ||
| 54 | 52 | ## Documentation | |
| @@ -56,8 +54,7 @@ CGO_ENABLED=0 go run ./examples/demo | |||
| 56 | 54 | - [Controls](docs/controls.md) and [layout](docs/layout.md) | |
| 57 | 55 | - [CSS properties and limitations](docs/css.md) | |
| 58 | 56 | - [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) | |
| 61 | 58 | - [Architecture decisions](docs/adr) | |
| 62 | 59 | ||
| 63 | 60 | ## 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 | |||
| 4 | 4 | ||
| 5 | 5 | ## Decision | |
| 6 | 6 | ||
| 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`. | |
| 8 | 8 | ||
| 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. | |
| 13 | 13 | - Shaders are GLSL compiled by `glslc` through `go generate`. SPIR-V is committed and embedded; consumers do not need `glslc`. | |
| 14 | 14 | ||
| 15 | 15 | ## Buffer lifecycle | |
| 16 | 16 | ||
| 17 | 17 | ```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 | |
| 20 | 20 | ``` | |
| 21 | 21 | ||
| 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. | |
| 23 | 23 | ||
| 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`. | |
| 25 | 25 | ||
| 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. | |
| 27 | 27 | ||
| 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 | |||
| 10 | 10 | ||
| 11 | 11 | 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`. | |
| 12 | 12 | ||
| 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`. | |
| 14 | 14 | ||
| 15 | 15 | `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. | |
| 16 | 16 | ||
| 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`. | |
| 18 | 18 | ||
| 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. | |
| 20 | 20 | ||
| 21 | 21 | 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. | |
| 22 | 22 | ||
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 | |||
| 6 | 6 | ||
| 7 | 7 | 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. | |
| 8 | 8 | ||
| 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. | |
| 10 | 10 | ||
| 11 | 11 | | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op | | |
| 12 | 12 | |---|---:|---:|---:|---:|---:|---:| | |
| @@ -29,24 +29,17 @@ The demo-frame benchmark builds the light, comfortable document workspace with t | |||
| 29 | 29 | ||
| 30 | 30 | 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. | |
| 31 | 31 | ||
| 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. | |
| 33 | 33 | ||
| 34 | 34 | Frame construction limits allocation churn through: | |
| 35 | 35 | ||
| 36 | 36 | - 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; | |
| 37 | 37 | - 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; | |
| 39 | 39 | - font family normalization at font load and identity path keys computed once per element; | |
| 40 | 40 | - `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. | |
| 41 | 41 | ||
| 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. | |
| 50 | 43 | ||
| 51 | 44 | Profile frame construction with: | |
| 52 | 45 | ||
| @@ -77,14 +70,10 @@ An external Go module with minimal-counter and demo views, a 960×640 window at | |||
| 77 | 70 | ||
| 78 | 71 | 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. | |
| 79 | 72 | ||
| 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. | |
| 81 | 74 | ||
| 82 | 75 | 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. | |
| 83 | 76 | ||
| 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. | |
| 89 | 78 | ||
| 90 | 79 | 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 @@ | |||
| 1 | 1 | # Immediate runtime | |
| 2 | 2 | ||
| 3 | - | `Run(ctx, &model, view, options...)` opens a Wayland surface, renders with Vulkan and returns when the surface closes (`nil`) or `ctx` is cancelled. It uses an xdg toplevel by default; `Layer(LayerConfig)` chooses a layer-shell role with an output name, layer, anchors, margins, exclusive zone and keyboard mode. `Title`, `Size` (logical pixels), `Styles` (CSS file path) and `Transparent` configure the surface. Setup and teardown errors are returned; joined errors retain real failures alongside cancellation. Accessibility is not connected. | |
| 3 | + | `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. | |
| 4 | 4 | ||
| 5 | - | `LayerConfig.AllOutputs` puts one layer surface on every output through one Wayland connection and owner loop. Outputs added later gain a surface; removed outputs lose theirs. Every surface runs the same view and model, laid out at its own size and scale; `Wake` and `OnInput` returning true redraw them all. `Run` then returns only on cancellation or failure. It cannot be combined with `Output`, keyboard interactivity, `OnSurface`, `OnResize` or `RunFrames`. | |
| 5 | + | A client loop forwards events and presents frames: | |
| 6 | 6 | ||
| 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. | |
| 8 | 13 | ||
| 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). | |
| 10 | 15 | ||
| 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. | |
| 12 | 17 | ||
| 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. | |
| 18 | 19 | ||
| 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. | |
| 26 | 21 | ||
| 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. | |
| 28 | 23 | ||
| 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`. | |
| 30 | 25 | ||
| 31 | 26 | 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. | |
| 32 | 27 | ||
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 @@ | |||
| 1 | 1 | # Troubleshooting | |
| 2 | 2 | ||
| 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. | |
| 5 | 4 | - 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. | |
| 6 | 5 | - Fonts: `nefergui: fonts: no usable fonts: ...` or `nefergui: no usable fonts` means system font discovery failed. Install usable TrueType/OpenType system fonts. | |
| 7 | 6 | - `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). | |
| 9 | 8 | - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`. | |
| 10 | 9 | - 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 @@ | |||
| 1 | 1 | package nefergui_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "fmt" | |
| 6 | 5 | "log" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/nefergui" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| 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) { | |
| 16 | 18 | root := f.Root().Column() | |
| 17 | 19 | root.Text("Hello, Wayland!") | |
| 18 | 20 | if root.Button("Count", nefergui.Key("count")).Activated() { | |
| 19 | - | *count++ | |
| 21 | + | m.count++ | |
| 20 | 22 | } | |
| 21 | - | root.Text(fmt.Sprintf("Clicked %d times", *count)) | |
| 23 | + | root.Text(fmt.Sprintf("Clicked %d times", m.count)) | |
| 22 | 24 | } | |
| 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 { | |
| 25 | 33 | log.Fatal(err) | |
| 26 | 34 | } | |
| 27 | - | } | |
| 35 | + | defer r.Close() | |
| 28 | 36 | ||
| 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) | |
| 55 | 47 | } | |
| 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 | |
| 72 | 53 | } | |
| 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. | |
| 74 | 56 | } | |
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 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego v0.13.0-bnema.1 | |
| 7 | 7 | 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 | |
| 10 | 8 | github.com/go-text/typesetting v0.3.5 | |
| 9 | + | github.com/stretchr/testify v1.12.1 | |
| 11 | 10 | golang.org/x/image v0.46.0 | |
| 12 | 11 | golang.org/x/sys v0.48.0 | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 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 ( | |||
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | 6 | "unicode/utf8" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | type memoryClipboard struct{ data []byte } | |
| 10 | 7 | ||
| 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 | + | ) | |
| 19 | 10 | ||
| 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) {} | |
| 25 | 11 | func TestGraphemesAndIME(t *testing.T) { | |
| 26 | 12 | s := State{} | |
| 27 | 13 | s.Sync("e\u0301👩<U+200D>💻a") | |
| @@ -46,33 +32,41 @@ func TestGraphemesAndIME(t *testing.T) { | |||
| 46 | 32 | if s.Preedit != "候補" || s.Value != "éa" { | |
| 47 | 33 | t.Fatal(s) | |
| 48 | 34 | } | |
| 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() | |
| 50 | 41 | s.Focus(ime, false, false) | |
| 51 | 42 | 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) | |
| 54 | 45 | } | |
| 55 | 46 | } | |
| 56 | 47 | func TestClipboardAndMask(t *testing.T) { | |
| 57 | 48 | s := State{} | |
| 58 | 49 | s.Sync("s3crét") | |
| 59 | 50 | 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 { | |
| 62 | 53 | t.Fatal(err) | |
| 63 | 54 | } | |
| 64 | 55 | if s.Mask() != "••••••" { | |
| 65 | 56 | t.Fatal(s.Mask()) | |
| 66 | 57 | } | |
| 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}) | |
| 68 | 62 | if changed, _ := s.Paste(c); changed { | |
| 69 | 63 | t.Fatal(s) | |
| 70 | 64 | } | |
| 71 | - | c.data = []byte(strings.Repeat("x", MaxClipboardBytes+1)) | |
| 65 | + | read([]byte(strings.Repeat("x", MaxClipboardBytes+1))) | |
| 72 | 66 | if changed, _ := s.Paste(c); changed { | |
| 73 | 67 | t.Fatal(s) | |
| 74 | 68 | } | |
| 75 | - | c.data = []byte("ok") | |
| 69 | + | read([]byte("ok")) | |
| 76 | 70 | if changed, _ := s.Paste(c); !changed || s.Value != "ok" { | |
| 77 | 71 | t.Fatal(s) | |
| 78 | 72 | } | |
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
| @@ -1,2084 +0,0 @@ | |||
| 1 | - | xkb_keymap { | |
| 2 | - | xkb_keycodes "evdev_aliases(azerty)" { | |
| 3 | - | minimum = 8; | |
| 4 | - | maximum = 709; | |
| 5 | - | <ESC> = 9; | |
| 6 | - | <AE01> = 10; | |
| 7 | - | <AE02> = 11; | |
| 8 | - | <AE03> = 12; | |
| 9 | - | <AE04> = 13; | |
| 10 | - | <AE05> = 14; | |
| 11 | - | <AE06> = 15; | |
| 12 | - | <AE07> = 16; | |
| 13 | - | <AE08> = 17; | |
| 14 | - | <AE09> = 18; | |
| 15 | - | <AE10> = 19; | |
| 16 | - | <AE11> = 20; | |
| 17 | - | <AE12> = 21; | |
| 18 | - | <BKSP> = 22; | |
| 19 | - | <TAB> = 23; | |
| 20 | - | <AD01> = 24; | |
| 21 | - | <AD02> = 25; | |
| 22 | - | <AD03> = 26; | |
| 23 | - | <AD04> = 27; | |
| 24 | - | <AD05> = 28; | |
| 25 | - | <AD06> = 29; | |
| 26 | - | <AD07> = 30; | |
| 27 | - | <AD08> = 31; | |
| 28 | - | <AD09> = 32; | |
| 29 | - | <AD10> = 33; | |
| 30 | - | <AD11> = 34; | |
| 31 | - | <AD12> = 35; | |
| 32 | - | <RTRN> = 36; | |
| 33 | - | <LCTL> = 37; | |
| 34 | - | <AC01> = 38; | |
| 35 | - | <AC02> = 39; | |
| 36 | - | <AC03> = 40; | |
| 37 | - | <AC04> = 41; | |
| 38 | - | <AC05> = 42; | |
| 39 | - | <AC06> = 43; | |
| 40 | - | <AC07> = 44; | |
| 41 | - | <AC08> = 45; | |
| 42 | - | <AC09> = 46; | |
| 43 | - | <AC10> = 47; | |
| 44 | - | <AC11> = 48; | |
| 45 | - | <TLDE> = 49; | |
| 46 | - | <LFSH> = 50; | |
| 47 | - | <BKSL> = 51; | |
| 48 | - | <AB01> = 52; | |
| 49 | - | <AB02> = 53; | |
| 50 | - | <AB03> = 54; | |
| 51 | - | <AB04> = 55; | |
| 52 | - | <AB05> = 56; | |
| 53 | - | <AB06> = 57; | |
| 54 | - | <AB07> = 58; | |
| 55 | - | <AB08> = 59; | |
| 56 | - | <AB09> = 60; | |
| 57 | - | <AB10> = 61; | |
| 58 | - | <RTSH> = 62; | |
| 59 | - | <KPMU> = 63; | |
| 60 | - | <LALT> = 64; | |
| 61 | - | <SPCE> = 65; | |
| 62 | - | <CAPS> = 66; | |
| 63 | - | <FK01> = 67; | |
| 64 | - | <FK02> = 68; | |
| 65 | - | <FK03> = 69; | |
| 66 | - | <FK04> = 70; | |
| 67 | - | <FK05> = 71; | |
| 68 | - | <FK06> = 72; | |
| 69 | - | <FK07> = 73; | |
| 70 | - | <FK08> = 74; | |
| 71 | - | <FK09> = 75; | |
| 72 | - | <FK10> = 76; | |
| 73 | - | <NMLK> = 77; | |
| 74 | - | <SCLK> = 78; | |
| 75 | - | <KP7> = 79; | |
| 76 | - | <KP8> = 80; | |
| 77 | - | <KP9> = 81; | |
| 78 | - | <KPSU> = 82; | |
| 79 | - | <KP4> = 83; | |
| 80 | - | <KP5> = 84; | |
| 81 | - | <KP6> = 85; | |
| 82 | - | <KPAD> = 86; | |
| 83 | - | <KP1> = 87; | |
| 84 | - | <KP2> = 88; | |
| 85 | - | <KP3> = 89; | |
| 86 | - | <KP0> = 90; | |
| 87 | - | <KPDL> = 91; | |
| 88 | - | <LVL3> = 92; | |
| 89 | - | <ZEHA> = 93; | |
| 90 | - | <LSGT> = 94; | |
| 91 | - | <FK11> = 95; | |
| 92 | - | <FK12> = 96; | |
| 93 | - | <AB11> = 97; | |
| 94 | - | <KATA> = 98; | |
| 95 | - | <HIRA> = 99; | |
| 96 | - | <HENK> = 100; | |
| 97 | - | <HKTG> = 101; | |
| 98 | - | <MUHE> = 102; | |
| 99 | - | <JPCM> = 103; | |
| 100 | - | <KPEN> = 104; | |
| 101 | - | <RCTL> = 105; | |
| 102 | - | <KPDV> = 106; | |
| 103 | - | <PRSC> = 107; | |
| 104 | - | <RALT> = 108; | |
| 105 | - | <LNFD> = 109; | |
| 106 | - | <HOME> = 110; | |
| 107 | - | <UP> = 111; | |
| 108 | - | <PGUP> = 112; | |
| 109 | - | <LEFT> = 113; | |
| 110 | - | <RGHT> = 114; | |
| 111 | - | <END> = 115; | |
| 112 | - | <DOWN> = 116; | |
| 113 | - | <PGDN> = 117; | |
| 114 | - | <INS> = 118; | |
| 115 | - | <DELE> = 119; | |
| 116 | - | <I120> = 120; | |
| 117 | - | <MUTE> = 121; | |
| 118 | - | <VOL-> = 122; | |
| 119 | - | <VOL+> = 123; | |
| 120 | - | <POWR> = 124; | |
| 121 | - | <KPEQ> = 125; | |
| 122 | - | <I126> = 126; | |
| 123 | - | <PAUS> = 127; | |
| 124 | - | <I128> = 128; | |
| 125 | - | <I129> = 129; | |
| 126 | - | <HNGL> = 130; | |
| 127 | - | <HJCV> = 131; | |
| 128 | - | <AE13> = 132; | |
| 129 | - | <LWIN> = 133; | |
| 130 | - | <RWIN> = 134; | |
| 131 | - | <COMP> = 135; | |
| 132 | - | <STOP> = 136; | |
| 133 | - | <AGAI> = 137; | |
| 134 | - | <PROP> = 138; | |
| 135 | - | <UNDO> = 139; | |
| 136 | - | <FRNT> = 140; | |
| 137 | - | <COPY> = 141; | |
| 138 | - | <OPEN> = 142; | |
| 139 | - | <PAST> = 143; | |
| 140 | - | <FIND> = 144; | |
| 141 | - | <CUT> = 145; | |
| 142 | - | <HELP> = 146; | |
| 143 | - | <I147> = 147; | |
| 144 | - | <I148> = 148; | |
| 145 | - | <I149> = 149; | |
| 146 | - | <I150> = 150; | |
| 147 | - | <I151> = 151; | |
| 148 | - | <I152> = 152; | |
| 149 | - | <I153> = 153; | |
| 150 | - | <I154> = 154; | |
| 151 | - | <I155> = 155; | |
| 152 | - | <I156> = 156; | |
| 153 | - | <I157> = 157; | |
| 154 | - | <I158> = 158; | |
| 155 | - | <I159> = 159; | |
| 156 | - | <I160> = 160; | |
| 157 | - | <I161> = 161; | |
| 158 | - | <I162> = 162; | |
| 159 | - | <I163> = 163; | |
| 160 | - | <I164> = 164; | |
| 161 | - | <I165> = 165; | |
| 162 | - | <I166> = 166; | |
| 163 | - | <I167> = 167; | |
| 164 | - | <I168> = 168; | |
| 165 | - | <I169> = 169; | |
| 166 | - | <I170> = 170; | |
| 167 | - | <I171> = 171; | |
| 168 | - | <I172> = 172; | |
| 169 | - | <I173> = 173; | |
| 170 | - | <I174> = 174; | |
| 171 | - | <I175> = 175; | |
| 172 | - | <I176> = 176; | |
| 173 | - | <I177> = 177; | |
| 174 | - | <I178> = 178; | |
| 175 | - | <I179> = 179; | |
| 176 | - | <I180> = 180; | |
| 177 | - | <I181> = 181; | |
| 178 | - | <I182> = 182; | |
| 179 | - | <I183> = 183; | |
| 180 | - | <I184> = 184; | |
| 181 | - | <I185> = 185; | |
| 182 | - | <I186> = 186; | |
| 183 | - | <I187> = 187; | |
| 184 | - | <I188> = 188; | |
| 185 | - | <I189> = 189; | |
| 186 | - | <I190> = 190; | |
| 187 | - | <FK13> = 191; | |
| 188 | - | <FK14> = 192; | |
| 189 | - | <FK15> = 193; | |
| 190 | - | <FK16> = 194; | |
| 191 | - | <FK17> = 195; | |
| 192 | - | <FK18> = 196; | |
| 193 | - | <FK19> = 197; | |
| 194 | - | <FK20> = 198; | |
| 195 | - | <FK21> = 199; | |
| 196 | - | <FK22> = 200; | |
| 197 | - | <FK23> = 201; | |
| 198 | - | <FK24> = 202; | |
| 199 | - | <LVL5> = 203; | |
| 200 | - | <ALT> = 204; | |
| 201 | - | <META> = 205; | |
| 202 | - | <SUPR> = 206; | |
| 203 | - | <HYPR> = 207; | |
| 204 | - | <I208> = 208; | |
| 205 | - | <I209> = 209; | |
| 206 | - | <I210> = 210; | |
| 207 | - | <I211> = 211; | |
| 208 | - | <I212> = 212; | |
| 209 | - | <I213> = 213; | |
| 210 | - | <I214> = 214; | |
| 211 | - | <I215> = 215; | |
| 212 | - | <I216> = 216; | |
| 213 | - | <I217> = 217; | |
| 214 | - | <I218> = 218; | |
| 215 | - | <I219> = 219; | |
| 216 | - | <I220> = 220; | |
| 217 | - | <I221> = 221; | |
| 218 | - | <I222> = 222; | |
| 219 | - | <I223> = 223; | |
| 220 | - | <I224> = 224; | |
| 221 | - | <I225> = 225; | |
| 222 | - | <I226> = 226; | |
| 223 | - | <I227> = 227; | |
| 224 | - | <I228> = 228; | |
| 225 | - | <I229> = 229; | |
| 226 | - | <I230> = 230; | |
| 227 | - | <I231> = 231; | |
| 228 | - | <I232> = 232; | |
| 229 | - | <I233> = 233; | |
| 230 | - | <I234> = 234; | |
| 231 | - | <I235> = 235; | |
| 232 | - | <I236> = 236; | |
| 233 | - | <I237> = 237; | |
| 234 | - | <I238> = 238; | |
| 235 | - | <I239> = 239; | |
| 236 | - | <I240> = 240; | |
| 237 | - | <I241> = 241; | |
| 238 | - | <I242> = 242; | |
| 239 | - | <I243> = 243; | |
| 240 | - | <I244> = 244; | |
| 241 | - | <I245> = 245; | |
| 242 | - | <I246> = 246; | |
| 243 | - | <I247> = 247; | |
| 244 | - | <I248> = 248; | |
| 245 | - | <I249> = 249; | |
| 246 | - | <I250> = 250; | |
| 247 | - | <I251> = 251; | |
| 248 | - | <I252> = 252; | |
| 249 | - | <I253> = 253; | |
| 250 | - | <I254> = 254; | |
| 251 | - | <I255> = 255; | |
| 252 | - | <I256> = 256; | |
| 253 | - | <I360> = 360; | |
| 254 | - | <I361> = 361; | |
| 255 | - | <I362> = 362; | |
| 256 | - | <I363> = 363; | |
| 257 | - | <I364> = 364; | |
| 258 | - | <I365> = 365; | |
| 259 | - | <I366> = 366; | |
| 260 | - | <I367> = 367; | |
| 261 | - | <I368> = 368; | |
| 262 | - | <I369> = 369; | |
| 263 | - | <I370> = 370; | |
| 264 | - | <I371> = 371; | |
| 265 | - | <I372> = 372; | |
| 266 | - | <I373> = 373; | |
| 267 | - | <I374> = 374; | |
| 268 | - | <I375> = 375; | |
| 269 | - | <I376> = 376; | |
| 270 | - | <I377> = 377; | |
| 271 | - | <I378> = 378; | |
| 272 | - | <I379> = 379; | |
| 273 | - | <I380> = 380; | |
| 274 | - | <I381> = 381; | |
| 275 | - | <I382> = 382; | |
| 276 | - | <I383> = 383; | |
| 277 | - | <I384> = 384; | |
| 278 | - | <I385> = 385; | |
| 279 | - | <I386> = 386; | |
| 280 | - | <I387> = 387; | |
| 281 | - | <I388> = 388; | |
| 282 | - | <I389> = 389; | |
| 283 | - | <I390> = 390; | |
| 284 | - | <I391> = 391; | |
| 285 | - | <I392> = 392; | |
| 286 | - | <I393> = 393; | |
| 287 | - | <I394> = 394; | |
| 288 | - | <I395> = 395; | |
| 289 | - | <I396> = 396; | |
| 290 | - | <I397> = 397; | |
| 291 | - | <I398> = 398; | |
| 292 | - | <I399> = 399; | |
| 293 | - | <I400> = 400; | |
| 294 | - | <I401> = 401; | |
| 295 | - | <I402> = 402; | |
| 296 | - | <I403> = 403; | |
| 297 | - | <I404> = 404; | |
| 298 | - | <I405> = 405; | |
| 299 | - | <I406> = 406; | |
| 300 | - | <I407> = 407; | |
| 301 | - | <I408> = 408; | |
| 302 | - | <I409> = 409; | |
| 303 | - | <I410> = 410; | |
| 304 | - | <I411> = 411; | |
| 305 | - | <I412> = 412; | |
| 306 | - | <I413> = 413; | |
| 307 | - | <I414> = 414; | |
| 308 | - | <I415> = 415; | |
| 309 | - | <I416> = 416; | |
| 310 | - | <I417> = 417; | |
| 311 | - | <I418> = 418; | |
| 312 | - | <I419> = 419; | |
| 313 | - | <I420> = 420; | |
| 314 | - | <I421> = 421; | |
| 315 | - | <I422> = 422; | |
| 316 | - | <I423> = 423; | |
| 317 | - | <I424> = 424; | |
| 318 | - | <I425> = 425; | |
| 319 | - | <I426> = 426; | |
| 320 | - | <I427> = 427; | |
| 321 | - | <I428> = 428; | |
| 322 | - | <I429> = 429; | |
| 323 | - | <I430> = 430; | |
| 324 | - | <I431> = 431; | |
| 325 | - | <I432> = 432; | |
| 326 | - | <I433> = 433; | |
| 327 | - | <I434> = 434; | |
| 328 | - | <I435> = 435; | |
| 329 | - | <I436> = 436; | |
| 330 | - | <I437> = 437; | |
| 331 | - | <I438> = 438; | |
| 332 | - | <I439> = 439; | |
| 333 | - | <I440> = 440; | |
| 334 | - | <I441> = 441; | |
| 335 | - | <I442> = 442; | |
| 336 | - | <I443> = 443; | |
| 337 | - | <I444> = 444; | |
| 338 | - | <I445> = 445; | |
| 339 | - | <I446> = 446; | |
| 340 | - | <I447> = 447; | |
| 341 | - | <I448> = 448; | |
| 342 | - | <I449> = 449; | |
| 343 | - | <I450> = 450; | |
| 344 | - | <I452> = 452; | |
| 345 | - | <I453> = 453; | |
| 346 | - | <I454> = 454; | |
| 347 | - | <I455> = 455; | |
| 348 | - | <I456> = 456; | |
| 349 | - | <I457> = 457; | |
| 350 | - | <I458> = 458; | |
| 351 | - | <I459> = 459; | |
| 352 | - | <I472> = 472; | |
| 353 | - | <I473> = 473; | |
| 354 | - | <I474> = 474; | |
| 355 | - | <I475> = 475; | |
| 356 | - | <I476> = 476; | |
| 357 | - | <I477> = 477; | |
| 358 | - | <I478> = 478; | |
| 359 | - | <I479> = 479; | |
| 360 | - | <I480> = 480; | |
| 361 | - | <I481> = 481; | |
| 362 | - | <I482> = 482; | |
| 363 | - | <I483> = 483; | |
| 364 | - | <I484> = 484; | |
| 365 | - | <I485> = 485; | |
| 366 | - | <I486> = 486; | |
| 367 | - | <I487> = 487; | |
| 368 | - | <I488> = 488; | |
| 369 | - | <I489> = 489; | |
| 370 | - | <I490> = 490; | |
| 371 | - | <I491> = 491; | |
| 372 | - | <I492> = 492; | |
| 373 | - | <I493> = 493; | |
| 374 | - | <I505> = 505; | |
| 375 | - | <I506> = 506; | |
| 376 | - | <I507> = 507; | |
| 377 | - | <I508> = 508; | |
| 378 | - | <I509> = 509; | |
| 379 | - | <I510> = 510; | |
| 380 | - | <I511> = 511; | |
| 381 | - | <I512> = 512; | |
| 382 | - | <I513> = 513; | |
| 383 | - | <I514> = 514; | |
| 384 | - | <I520> = 520; | |
| 385 | - | <I521> = 521; | |
| 386 | - | <I522> = 522; | |
| 387 | - | <I523> = 523; | |
| 388 | - | <I524> = 524; | |
| 389 | - | <I525> = 525; | |
| 390 | - | <I526> = 526; | |
| 391 | - | <I527> = 527; | |
| 392 | - | <I528> = 528; | |
| 393 | - | <I529> = 529; | |
| 394 | - | <I530> = 530; | |
| 395 | - | <I531> = 531; | |
| 396 | - | <I532> = 532; | |
| 397 | - | <I533> = 533; | |
| 398 | - | <I534> = 534; | |
| 399 | - | <I535> = 535; | |
| 400 | - | <I536> = 536; | |
| 401 | - | <I537> = 537; | |
| 402 | - | <I538> = 538; | |
| 403 | - | <I539> = 539; | |
| 404 | - | <I540> = 540; | |
| 405 | - | <I541> = 541; | |
| 406 | - | <I542> = 542; | |
| 407 | - | <I543> = 543; | |
| 408 | - | <I544> = 544; | |
| 409 | - | <I545> = 545; | |
| 410 | - | <I546> = 546; | |
| 411 | - | <I547> = 547; | |
| 412 | - | <I548> = 548; | |
| 413 | - | <I549> = 549; | |
| 414 | - | <I550> = 550; | |
| 415 | - | <I568> = 568; | |
| 416 | - | <I569> = 569; | |
| 417 | - | <I570> = 570; | |
| 418 | - | <I584> = 584; | |
| 419 | - | <I585> = 585; | |
| 420 | - | <I586> = 586; | |
| 421 | - | <I587> = 587; | |
| 422 | - | <I588> = 588; | |
| 423 | - | <I589> = 589; | |
| 424 | - | <I590> = 590; | |
| 425 | - | <I591> = 591; | |
| 426 | - | <I592> = 592; | |
| 427 | - | <I593> = 593; | |
| 428 | - | <I594> = 594; | |
| 429 | - | <I595> = 595; | |
| 430 | - | <I596> = 596; | |
| 431 | - | <I597> = 597; | |
| 432 | - | <I598> = 598; | |
| 433 | - | <I599> = 599; | |
| 434 | - | <I600> = 600; | |
| 435 | - | <I601> = 601; | |
| 436 | - | <I602> = 602; | |
| 437 | - | <I603> = 603; | |
| 438 | - | <I604> = 604; | |
| 439 | - | <I605> = 605; | |
| 440 | - | <I606> = 606; | |
| 441 | - | <I616> = 616; | |
| 442 | - | <I617> = 617; | |
| 443 | - | <I618> = 618; | |
| 444 | - | <I619> = 619; | |
| 445 | - | <I620> = 620; | |
| 446 | - | <I621> = 621; | |
| 447 | - | <I622> = 622; | |
| 448 | - | <I623> = 623; | |
| 449 | - | <I624> = 624; | |
| 450 | - | <I625> = 625; | |
| 451 | - | <I626> = 626; | |
| 452 | - | <I627> = 627; | |
| 453 | - | <I628> = 628; | |
| 454 | - | <I629> = 629; | |
| 455 | - | <I630> = 630; | |
| 456 | - | <I631> = 631; | |
| 457 | - | <I632> = 632; | |
| 458 | - | <I633> = 633; | |
| 459 | - | <I634> = 634; | |
| 460 | - | <I635> = 635; | |
| 461 | - | <I636> = 636; | |
| 462 | - | <I637> = 637; | |
| 463 | - | <I638> = 638; | |
| 464 | - | <I639> = 639; | |
| 465 | - | <I640> = 640; | |
| 466 | - | <I641> = 641; | |
| 467 | - | <I642> = 642; | |
| 468 | - | <I643> = 643; | |
| 469 | - | <I644> = 644; | |
| 470 | - | <I645> = 645; | |
| 471 | - | <I646> = 646; | |
| 472 | - | <I647> = 647; | |
| 473 | - | <I648> = 648; | |
| 474 | - | <I649> = 649; | |
| 475 | - | <I650> = 650; | |
| 476 | - | <I651> = 651; | |
| 477 | - | <I652> = 652; | |
| 478 | - | <I653> = 653; | |
| 479 | - | <I654> = 654; | |
| 480 | - | <I655> = 655; | |
| 481 | - | <I656> = 656; | |
| 482 | - | <I657> = 657; | |
| 483 | - | <I664> = 664; | |
| 484 | - | <I665> = 665; | |
| 485 | - | <I666> = 666; | |
| 486 | - | <I667> = 667; | |
| 487 | - | <I668> = 668; | |
| 488 | - | <I669> = 669; | |
| 489 | - | <I670> = 670; | |
| 490 | - | <I671> = 671; | |
| 491 | - | <I672> = 672; | |
| 492 | - | <I673> = 673; | |
| 493 | - | <I674> = 674; | |
| 494 | - | <I675> = 675; | |
| 495 | - | <I676> = 676; | |
| 496 | - | <I677> = 677; | |
| 497 | - | <I678> = 678; | |
| 498 | - | <I679> = 679; | |
| 499 | - | <I680> = 680; | |
| 500 | - | <I681> = 681; | |
| 501 | - | <I682> = 682; | |
| 502 | - | <I683> = 683; | |
| 503 | - | <I684> = 684; | |
| 504 | - | <I685> = 685; | |
| 505 | - | <I686> = 686; | |
| 506 | - | <I687> = 687; | |
| 507 | - | <I688> = 688; | |
| 508 | - | <I689> = 689; | |
| 509 | - | <I690> = 690; | |
| 510 | - | <I691> = 691; | |
| 511 | - | <I692> = 692; | |
| 512 | - | <I693> = 693; | |
| 513 | - | <I696> = 696; | |
| 514 | - | <I697> = 697; | |
| 515 | - | <I698> = 698; | |
| 516 | - | <I699> = 699; | |
| 517 | - | <I700> = 700; | |
| 518 | - | <I701> = 701; | |
| 519 | - | <I704> = 704; | |
| 520 | - | <I705> = 705; | |
| 521 | - | <I706> = 706; | |
| 522 | - | <I707> = 707; | |
| 523 | - | <I708> = 708; | |
| 524 | - | <I709> = 709; | |
| 525 | - | indicator 1 = "Caps Lock"; | |
| 526 | - | indicator 2 = "Num Lock"; | |
| 527 | - | indicator 3 = "Scroll Lock"; | |
| 528 | - | indicator 4 = "Compose"; | |
| 529 | - | indicator 5 = "Kana"; | |
| 530 | - | indicator 6 = "Sleep"; | |
| 531 | - | indicator 7 = "Suspend"; | |
| 532 | - | indicator 8 = "Mute"; | |
| 533 | - | indicator 9 = "Misc"; | |
| 534 | - | indicator 10 = "Mail"; | |
| 535 | - | indicator 11 = "Charging"; | |
| 536 | - | indicator 12 = "Shift Lock"; | |
| 537 | - | indicator 13 = "Group 2"; | |
| 538 | - | indicator 14 = "Mouse Keys"; | |
| 539 | - | alias <AC12> = <BKSL>; | |
| 540 | - | alias <ALGR> = <RALT>; | |
| 541 | - | alias <MENU> = <COMP>; | |
| 542 | - | alias <HZTG> = <TLDE>; | |
| 543 | - | alias <LMTA> = <LWIN>; | |
| 544 | - | alias <RMTA> = <RWIN>; | |
| 545 | - | alias <OUTP> = <I235>; | |
| 546 | - | alias <KITG> = <I236>; | |
| 547 | - | alias <KIDN> = <I237>; | |
| 548 | - | alias <KIUP> = <I238>; | |
| 549 | - | alias <I121> = <MUTE>; | |
| 550 | - | alias <I122> = <VOL->; | |
| 551 | - | alias <I123> = <VOL+>; | |
| 552 | - | alias <I124> = <POWR>; | |
| 553 | - | alias <I125> = <KPEQ>; | |
| 554 | - | alias <I127> = <PAUS>; | |
| 555 | - | alias <I130> = <HNGL>; | |
| 556 | - | alias <I131> = <HJCV>; | |
| 557 | - | alias <I132> = <AE13>; | |
| 558 | - | alias <I133> = <LWIN>; | |
| 559 | - | alias <I134> = <RWIN>; | |
| 560 | - | alias <I135> = <COMP>; | |
| 561 | - | alias <I136> = <STOP>; | |
| 562 | - | alias <I137> = <AGAI>; | |
| 563 | - | alias <I138> = <PROP>; | |
| 564 | - | alias <I139> = <UNDO>; | |
| 565 | - | alias <I140> = <FRNT>; | |
| 566 | - | alias <I141> = <COPY>; | |
| 567 | - | alias <I142> = <OPEN>; | |
| 568 | - | alias <I143> = <PAST>; | |
| 569 | - | alias <I144> = <FIND>; | |
| 570 | - | alias <I145> = <CUT>; | |
| 571 | - | alias <I146> = <HELP>; | |
| 572 | - | alias <I191> = <FK13>; | |
| 573 | - | alias <I192> = <FK14>; | |
| 574 | - | alias <I193> = <FK15>; | |
| 575 | - | alias <I194> = <FK16>; | |
| 576 | - | alias <I195> = <FK17>; | |
| 577 | - | alias <I196> = <FK18>; | |
| 578 | - | alias <I197> = <FK19>; | |
| 579 | - | alias <I198> = <FK20>; | |
| 580 | - | alias <I199> = <FK21>; | |
| 581 | - | alias <I200> = <FK22>; | |
| 582 | - | alias <I201> = <FK23>; | |
| 583 | - | alias <I202> = <FK24>; | |
| 584 | - | alias <MDSW> = <LVL5>; | |
| 585 | - | alias <KPPT> = <I129>; | |
| 586 | - | alias <LatQ> = <AC01>; | |
| 587 | - | alias <LatW> = <AB01>; | |
| 588 | - | alias <LatE> = <AD03>; | |
| 589 | - | alias <LatR> = <AD04>; | |
| 590 | - | alias <LatT> = <AD05>; | |
| 591 | - | alias <LatY> = <AD06>; | |
| 592 | - | alias <LatU> = <AD07>; | |
| 593 | - | alias <LatI> = <AD08>; | |
| 594 | - | alias <LatO> = <AD09>; | |
| 595 | - | alias <LatP> = <AD10>; | |
| 596 | - | alias <LatA> = <AD01>; | |
| 597 | - | alias <LatS> = <AC02>; | |
| 598 | - | alias <LatD> = <AC03>; | |
| 599 | - | alias <LatF> = <AC04>; | |
| 600 | - | alias <LatG> = <AC05>; | |
| 601 | - | alias <LatH> = <AC06>; | |
| 602 | - | alias <LatJ> = <AC07>; | |
| 603 | - | alias <LatK> = <AC08>; | |
| 604 | - | alias <LatL> = <AC09>; | |
| 605 | - | alias <LatZ> = <AD02>; | |
| 606 | - | alias <LatX> = <AB02>; | |
| 607 | - | alias <LatC> = <AB03>; | |
| 608 | - | alias <LatV> = <AB04>; | |
| 609 | - | alias <LatB> = <AB05>; | |
| 610 | - | alias <LatN> = <AB06>; | |
| 611 | - | alias <LatM> = <AC10>; | |
| 612 | - | }; | |
| 613 | - | ||
| 614 | - | xkb_types "complete" { | |
| 615 | - | virtual_modifiers NumLock,Alt,LevelThree,Super,LevelFive,Meta,Hyper=0x4000,ScrollLock=0x8000; | |
| 616 | - | ||
| 617 | - | type "ONE_LEVEL" { | |
| 618 | - | modifiers= none; | |
| 619 | - | level_name[1]= "Any"; | |
| 620 | - | }; | |
| 621 | - | type "TWO_LEVEL" { | |
| 622 | - | modifiers= Shift; | |
| 623 | - | map[Shift]= 2; | |
| 624 | - | level_name[1]= "Base"; | |
| 625 | - | level_name[2]= "Shift"; | |
| 626 | - | }; | |
| 627 | - | type "ALPHABETIC" { | |
| 628 | - | modifiers= Shift+Lock; | |
| 629 | - | map[Shift]= 2; | |
| 630 | - | map[Lock]= 2; | |
| 631 | - | level_name[1]= "Base"; | |
| 632 | - | level_name[2]= "Caps"; | |
| 633 | - | }; | |
| 634 | - | type "PC_ALT_LEVEL2" { | |
| 635 | - | modifiers= Alt; | |
| 636 | - | map[Alt]= 2; | |
| 637 | - | level_name[1]= "Base"; | |
| 638 | - | level_name[2]= "Alt"; | |
| 639 | - | }; | |
| 640 | - | type "PC_SUPER_LEVEL2" { | |
| 641 | - | modifiers= Super; | |
| 642 | - | map[Super]= 2; | |
| 643 | - | level_name[1]= "Base"; | |
| 644 | - | level_name[2]= "Super"; | |
| 645 | - | }; | |
| 646 | - | type "PC_SHIFT_SUPER_LEVEL2" { | |
| 647 | - | modifiers= Shift+Super; | |
| 648 | - | map[Shift+Super]= 2; | |
| 649 | - | map[Shift]= 1; | |
| 650 | - | preserve[Shift]= Shift; | |
| 651 | - | map[Super]= 1; | |
| 652 | - | preserve[Super]= Super; | |
| 653 | - | level_name[1]= "Base"; | |
| 654 | - | level_name[2]= "Super"; | |
| 655 | - | }; | |
| 656 | - | type "PC_CONTROL_LEVEL2" { | |
| 657 | - | modifiers= Control; | |
| 658 | - | map[Control]= 2; | |
| 659 | - | level_name[1]= "Base"; | |
| 660 | - | level_name[2]= "Control"; | |
| 661 | - | }; | |
| 662 | - | type "PC_CONTROL_SUPER_LEVEL2" { | |
| 663 | - | modifiers= Control+Super; | |
| 664 | - | map[Control+Super]= 2; | |
| 665 | - | map[Control]= 1; | |
| 666 | - | preserve[Control]= Control; | |
| 667 | - | map[Super]= 1; | |
| 668 | - | preserve[Super]= Super; | |
| 669 | - | level_name[1]= "Base"; | |
| 670 | - | level_name[2]= "Control + Super"; | |
| 671 | - | }; | |
| 672 | - | type "CTRL+ALT" { | |
| 673 | - | modifiers= Shift+Control+Alt+LevelThree; | |
| 674 | - | map[Shift]= 2; | |
| 675 | - | map[LevelThree]= 3; | |
| 676 | - | map[Shift+LevelThree]= 4; | |
| 677 | - | map[Control]= 1; | |
| 678 | - | preserve[Control]= Control; | |
| 679 | - | map[Shift+Control]= 2; | |
| 680 | - | preserve[Shift+Control]= Control; | |
| 681 | - | map[Control+LevelThree]= 3; | |
| 682 | - | preserve[Control+LevelThree]= Control; | |
| 683 | - | map[Shift+Control+LevelThree]= 4; | |
| 684 | - | preserve[Shift+Control+LevelThree]= Control; | |
| 685 | - | map[Alt]= 1; | |
| 686 | - | preserve[Alt]= Alt; | |
| 687 | - | map[Shift+Alt]= 2; | |
| 688 | - | preserve[Shift+Alt]= Alt; | |
| 689 | - | map[Alt+LevelThree]= 3; | |
| 690 | - | preserve[Alt+LevelThree]= Alt; | |
| 691 | - | map[Shift+Alt+LevelThree]= 4; | |
| 692 | - | preserve[Shift+Alt+LevelThree]= Alt; | |
| 693 | - | map[Control+Alt]= 5; | |
| 694 | - | map[Shift+Control+Alt]= 2; | |
| 695 | - | preserve[Shift+Control+Alt]= Control+Alt; | |
| 696 | - | map[Control+Alt+LevelThree]= 3; | |
| 697 | - | preserve[Control+Alt+LevelThree]= Control+Alt; | |
| 698 | - | map[Shift+Control+Alt+LevelThree]= 4; | |
| 699 | - | preserve[Shift+Control+Alt+LevelThree]= Control+Alt; | |
| 700 | - | level_name[1]= "Base"; | |
| 701 | - | level_name[2]= "Shift"; | |
| 702 | - | level_name[3]= "AltGr"; | |
| 703 | - | level_name[4]= "Shift AltGr"; | |
| 704 | - | level_name[5]= "Ctrl+Alt"; | |
| 705 | - | }; | |
| 706 | - | type "THREE_LEVEL" { | |
| 707 | - | modifiers= Shift+LevelThree; | |
| 708 | - | map[Shift]= 2; | |
| 709 | - | map[LevelThree]= 3; | |
| 710 | - | map[Shift+LevelThree]= 3; | |
| 711 | - | level_name[1]= "Base"; | |
| 712 | - | level_name[2]= "Shift"; | |
| 713 | - | level_name[3]= "Level3"; | |
| 714 | - | }; | |
| 715 | - | type "FOUR_LEVEL" { | |
| 716 | - | modifiers= Shift+LevelThree; | |
| 717 | - | map[Shift]= 2; | |
| 718 | - | map[LevelThree]= 3; | |
| 719 | - | map[Shift+LevelThree]= 4; | |
| 720 | - | level_name[1]= "Base"; | |
| 721 | - | level_name[2]= "Shift"; | |
| 722 | - | level_name[3]= "AltGr"; | |
| 723 | - | level_name[4]= "Shift AltGr"; | |
| 724 | - | }; | |
| 725 | - | type "FOUR_LEVEL_ALPHABETIC" { | |
| 726 | - | modifiers= Shift+Lock+LevelThree; | |
| 727 | - | map[Shift]= 2; | |
| 728 | - | map[Lock]= 2; | |
| 729 | - | map[LevelThree]= 3; | |
| 730 | - | map[Shift+LevelThree]= 4; | |
| 731 | - | map[Lock+LevelThree]= 4; | |
| 732 | - | map[Shift+Lock+LevelThree]= 3; | |
| 733 | - | level_name[1]= "Base"; | |
| 734 | - | level_name[2]= "Shift"; | |
| 735 | - | level_name[3]= "AltGr"; | |
| 736 | - | level_name[4]= "Shift AltGr"; | |
| 737 | - | }; | |
| 738 | - | type "FOUR_LEVEL_SEMIALPHABETIC" { | |
| 739 | - | modifiers= Shift+Lock+LevelThree; | |
| 740 | - | map[Shift]= 2; | |
| 741 | - | map[Lock]= 2; | |
| 742 | - | map[LevelThree]= 3; | |
| 743 | - | map[Shift+LevelThree]= 4; | |
| 744 | - | map[Lock+LevelThree]= 3; | |
| 745 | - | preserve[Lock+LevelThree]= Lock; | |
| 746 | - | map[Shift+Lock+LevelThree]= 4; | |
| 747 | - | preserve[Shift+Lock+LevelThree]= Lock; | |
| 748 | - | level_name[1]= "Base"; | |
| 749 | - | level_name[2]= "Shift"; | |
| 750 | - | level_name[3]= "AltGr"; | |
| 751 | - | level_name[4]= "Shift AltGr"; | |
| 752 | - | }; | |
| 753 | - | type "FOUR_LEVEL_KEYPAD" { | |
| 754 | - | modifiers= Shift+NumLock+LevelThree; | |
| 755 | - | map[Shift]= 1; | |
| 756 | - | preserve[Shift]= Shift; | |
| 757 | - | map[NumLock]= 2; | |
| 758 | - | map[LevelThree]= 3; | |
| 759 | - | map[Shift+NumLock]= 2; | |
| 760 | - | preserve[Shift+NumLock]= Shift; | |
| 761 | - | map[Shift+LevelThree]= 4; | |
| 762 | - | map[NumLock+LevelThree]= 3; | |
| 763 | - | map[Shift+NumLock+LevelThree]= 4; | |
| 764 | - | level_name[1]= "Base"; | |
| 765 | - | level_name[2]= "NumLock"; | |
| 766 | - | level_name[3]= "AltGr"; | |
| 767 | - | level_name[4]= "Shift AltGr"; | |
| 768 | - | }; | |
| 769 | - | type "FOUR_LEVEL_MIXED_KEYPAD" { | |
| 770 | - | modifiers= Shift+NumLock+LevelThree; | |
| 771 | - | map[Shift]= 1; | |
| 772 | - | preserve[Shift]= Shift; | |
| 773 | - | map[NumLock]= 2; | |
| 774 | - | map[LevelThree]= 3; | |
| 775 | - | map[Shift+NumLock]= 2; | |
| 776 | - | preserve[Shift+NumLock]= Shift; | |
| 777 | - | map[Shift+LevelThree]= 4; | |
| 778 | - | map[NumLock+LevelThree]= 3; | |
| 779 | - | map[Shift+NumLock+LevelThree]= 4; | |
| 780 | - | level_name[1]= "Base"; | |
| 781 | - | level_name[2]= "NumLock"; | |
| 782 | - | level_name[3]= "AltGr"; | |
| 783 | - | level_name[4]= "Shift AltGr"; | |
| 784 | - | }; | |
| 785 | - | type "FOUR_LEVEL_LOCKABLE_LEVEL2" { | |
| 786 | - | modifiers= Shift+LevelThree+LevelFive; | |
| 787 | - | map[Shift]= 2; | |
| 788 | - | map[LevelFive]= 2; | |
| 789 | - | map[LevelThree]= 3; | |
| 790 | - | map[Shift+LevelThree]= 4; | |
| 791 | - | map[LevelThree+LevelFive]= 3; | |
| 792 | - | map[Shift+LevelThree+LevelFive]= 4; | |
| 793 | - | level_name[1]= "Base"; | |
| 794 | - | level_name[2]= "Digit"; | |
| 795 | - | level_name[3]= "AltGr"; | |
| 796 | - | level_name[4]= "Shift AltGr"; | |
| 797 | - | }; | |
| 798 | - | type "FOUR_LEVEL_X" { | |
| 799 | - | modifiers= Shift+Control+Alt+LevelThree; | |
| 800 | - | map[Shift]= 1; | |
| 801 | - | preserve[Shift]= Shift; | |
| 802 | - | map[LevelThree]= 2; | |
| 803 | - | map[Control]= 1; | |
| 804 | - | preserve[Control]= Control; | |
| 805 | - | map[Alt]= 1; | |
| 806 | - | preserve[Alt]= Alt; | |
| 807 | - | map[Shift+LevelThree]= 3; | |
| 808 | - | map[Shift+Control]= 1; | |
| 809 | - | preserve[Shift+Control]= Shift+Control; | |
| 810 | - | map[Shift+Alt]= 1; | |
| 811 | - | preserve[Shift+Alt]= Shift+Alt; | |
| 812 | - | map[Control+LevelThree]= 2; | |
| 813 | - | preserve[Control+LevelThree]= Control; | |
| 814 | - | map[Alt+LevelThree]= 2; | |
| 815 | - | preserve[Alt+LevelThree]= Alt; | |
| 816 | - | map[Control+Alt]= 4; | |
| 817 | - | map[Shift+Control+LevelThree]= 3; | |
| 818 | - | preserve[Shift+Control+LevelThree]= Control; | |
| 819 | - | map[Shift+Alt+LevelThree]= 3; | |
| 820 | - | preserve[Shift+Alt+LevelThree]= Alt; | |
| 821 | - | map[Shift+Control+Alt]= 4; | |
| 822 | - | preserve[Shift+Control+Alt]= Shift; | |
| 823 | - | map[Control+Alt+LevelThree]= 2; | |
| 824 | - | preserve[Control+Alt+LevelThree]= Control+Alt; | |
| 825 | - | map[Shift+Control+Alt+LevelThree]= 3; | |
| 826 | - | preserve[Shift+Control+Alt+LevelThree]= Control+Alt; | |
| 827 | - | level_name[1]= "Base"; | |
| 828 | - | level_name[2]= "AltGr"; | |
| 829 | - | level_name[3]= "Shift AltGr"; | |
| 830 | - | level_name[4]= "Ctrl+Alt"; | |
| 831 | - | }; | |
| 832 | - | type "SEPARATE_CAPS_AND_SHIFT_ALPHABETIC" { | |
| 833 | - | modifiers= Shift+Lock+LevelThree; | |
| 834 | - | map[Shift]= 2; | |
| 835 | - | map[Lock]= 4; | |
| 836 | - | preserve[Lock]= Lock; | |
| 837 | - | map[LevelThree]= 3; | |
| 838 | - | map[Shift+LevelThree]= 4; | |
| 839 | - | map[Lock+LevelThree]= 3; | |
| 840 | - | preserve[Lock+LevelThree]= Lock; | |
| 841 | - | map[Shift+Lock+LevelThree]= 3; | |
| 842 | - | level_name[1]= "Base"; | |
| 843 | - | level_name[2]= "Shift"; | |
| 844 | - | level_name[3]= "AltGr"; | |
| 845 | - | level_name[4]= "Caps / Shift AltGr"; | |
| 846 | - | }; | |
| 847 | - | type "FOUR_LEVEL_PLUS_LOCK" { | |
| 848 | - | modifiers= Shift+Lock+LevelThree; | |
| 849 | - | map[Shift]= 2; | |
| 850 | - | map[LevelThree]= 3; | |
| 851 | - | map[Shift+LevelThree]= 4; | |
| 852 | - | map[Lock]= 5; | |
| 853 | - | map[Shift+Lock]= 2; | |
| 854 | - | map[Lock+LevelThree]= 3; | |
| 855 | - | map[Shift+Lock+LevelThree]= 4; | |
| 856 | - | level_name[1]= "Base"; | |
| 857 | - | level_name[2]= "Shift"; | |
| 858 | - | level_name[3]= "AltGr"; | |
| 859 | - | level_name[4]= "Shift AltGr"; | |
| 860 | - | level_name[5]= "Lock"; | |
| 861 | - | }; | |
| 862 | - | type "EIGHT_LEVEL" { | |
| 863 | - | modifiers= Shift+LevelThree+LevelFive; | |
| 864 | - | map[Shift]= 2; | |
| 865 | - | map[LevelThree]= 3; | |
| 866 | - | map[Shift+LevelThree]= 4; | |
| 867 | - | map[LevelFive]= 5; | |
| 868 | - | map[Shift+LevelFive]= 6; | |
| 869 | - | map[LevelThree+LevelFive]= 7; | |
| 870 | - | map[Shift+LevelThree+LevelFive]= 8; | |
| 871 | - | level_name[1]= "Base"; | |
| 872 | - | level_name[2]= "Shift"; | |
| 873 | - | level_name[3]= "AltGr"; | |
| 874 | - | level_name[4]= "Shift AltGr"; | |
| 875 | - | level_name[5]= "X"; | |
| 876 | - | level_name[6]= "X Shift"; | |
| 877 | - | level_name[7]= "X AltGr"; | |
| 878 | - | level_name[8]= "X Shift AltGr"; | |
| 879 | - | }; | |
| 880 | - | type "EIGHT_LEVEL_ALPHABETIC" { | |
| 881 | - | modifiers= Shift+Lock+LevelThree+LevelFive; | |
| 882 | - | map[Shift]= 2; | |
| 883 | - | map[Lock]= 2; | |
| 884 | - | map[LevelThree]= 3; | |
| 885 | - | map[Shift+LevelThree]= 4; | |
| 886 | - | map[Lock+LevelThree]= 4; | |
| 887 | - | map[Shift+Lock+LevelThree]= 3; | |
| 888 | - | map[LevelFive]= 5; | |
| 889 | - | map[Shift+LevelFive]= 6; | |
| 890 | - | map[Lock+LevelFive]= 6; | |
| 891 | - | map[Shift+Lock+LevelFive]= 5; | |
| 892 | - | map[LevelThree+LevelFive]= 7; | |
| 893 | - | map[Shift+LevelThree+LevelFive]= 8; | |
| 894 | - | map[Lock+LevelThree+LevelFive]= 8; | |
| 895 | - | map[Shift+Lock+LevelThree+LevelFive]= 7; | |
| 896 | - | level_name[1]= "Base"; | |
| 897 | - | level_name[2]= "Shift"; | |
| 898 | - | level_name[3]= "AltGr"; | |
| 899 | - | level_name[4]= "Shift AltGr"; | |
| 900 | - | level_name[5]= "X"; | |
| 901 | - | level_name[6]= "X Shift"; | |
| 902 | - | level_name[7]= "X AltGr"; | |
| 903 | - | level_name[8]= "X Shift AltGr"; | |
| 904 | - | }; | |
| 905 | - | type "EIGHT_LEVEL_SEMIALPHABETIC" { | |
| 906 | - | modifiers= Shift+Lock+LevelThree+LevelFive; | |
| 907 | - | map[Shift]= 2; | |
| 908 | - | map[Lock]= 2; | |
| 909 | - | map[LevelThree]= 3; | |
| 910 | - | map[Shift+LevelThree]= 4; | |
| 911 | - | map[Lock+LevelThree]= 3; | |
| 912 | - | preserve[Lock+LevelThree]= Lock; | |
| 913 | - | map[Shift+Lock+LevelThree]= 4; | |
| 914 | - | preserve[Shift+Lock+LevelThree]= Lock; | |
| 915 | - | map[LevelFive]= 5; | |
| 916 | - | map[Shift+LevelFive]= 6; | |
| 917 | - | map[Lock+LevelFive]= 6; | |
| 918 | - | map[Shift+Lock+LevelFive]= 5; | |
| 919 | - | map[LevelThree+LevelFive]= 7; | |
| 920 | - | map[Shift+LevelThree+LevelFive]= 8; | |
| 921 | - | map[Lock+LevelThree+LevelFive]= 7; | |
| 922 | - | preserve[Lock+LevelThree+LevelFive]= Lock; | |
| 923 | - | map[Shift+Lock+LevelThree+LevelFive]= 8; | |
| 924 | - | preserve[Shift+Lock+LevelThree+LevelFive]= Lock; | |
| 925 | - | level_name[1]= "Base"; | |
| 926 | - | level_name[2]= "Shift"; | |
| 927 | - | level_name[3]= "AltGr"; | |
| 928 | - | level_name[4]= "Shift AltGr"; | |
| 929 | - | level_name[5]= "X"; | |
| 930 | - | level_name[6]= "X Shift"; | |
| 931 | - | level_name[7]= "X AltGr"; | |
| 932 | - | level_name[8]= "X Shift AltGr"; | |
| 933 | - | }; | |
| 934 | - | type "EIGHT_LEVEL_WITH_LEVEL5_LOCK" { | |
| 935 | - | modifiers= Shift+NumLock+LevelThree+LevelFive; | |
| 936 | - | map[Shift]= 2; | |
| 937 | - | map[LevelThree]= 3; | |
| 938 | - | map[Shift+LevelThree]= 4; | |
| 939 | - | map[LevelFive]= 5; | |
| 940 | - | map[Shift+LevelFive]= 6; | |
| 941 | - | preserve[Shift+LevelFive]= Shift; | |
| 942 | - | map[LevelThree+LevelFive]= 7; | |
| 943 | - | map[Shift+LevelThree+LevelFive]= 8; | |
| 944 | - | map[NumLock]= 5; | |
| 945 | - | map[Shift+NumLock]= 6; | |
| 946 | - | preserve[Shift+NumLock]= Shift; | |
| 947 | - | map[NumLock+LevelThree]= 7; | |
| 948 | - | map[Shift+NumLock+LevelThree]= 8; | |
| 949 | - | map[Shift+NumLock+LevelFive]= 2; | |
| 950 | - | map[NumLock+LevelThree+LevelFive]= 3; | |
| 951 | - | map[Shift+NumLock+LevelThree+LevelFive]= 4; | |
| 952 | - | level_name[1]= "Base"; | |
| 953 | - | level_name[2]= "Shift"; | |
| 954 | - | level_name[3]= "AltGr"; | |
| 955 | - | level_name[4]= "Shift AltGr"; | |
| 956 | - | level_name[5]= "X"; | |
| 957 | - | level_name[6]= "X Shift"; | |
| 958 | - | level_name[7]= "X AltGr"; | |
| 959 | - | level_name[8]= "X Shift AltGr"; | |
| 960 | - | }; | |
| 961 | - | type "EIGHT_LEVEL_ALPHABETIC_WITH_LEVEL5_LOCK" { | |
| 962 | - | modifiers= Shift+Lock+NumLock+LevelThree+LevelFive; | |
| 963 | - | map[Shift]= 2; | |
| 964 | - | map[LevelThree]= 3; | |
| 965 | - | map[Shift+LevelThree]= 4; | |
| 966 | - | map[LevelFive]= 5; | |
| 967 | - | map[Shift+LevelFive]= 6; | |
| 968 | - | preserve[Shift+LevelFive]= Shift; | |
| 969 | - | map[LevelThree+LevelFive]= 7; | |
| 970 | - | map[Shift+LevelThree+LevelFive]= 8; | |
| 971 | - | map[NumLock]= 5; | |
| 972 | - | map[Shift+NumLock]= 6; | |
| 973 | - | preserve[Shift+NumLock]= Shift; | |
| 974 | - | map[NumLock+LevelThree]= 7; | |
| 975 | - | map[Shift+NumLock+LevelThree]= 8; | |
| 976 | - | map[Shift+NumLock+LevelFive]= 2; | |
| 977 | - | map[NumLock+LevelThree+LevelFive]= 3; | |
| 978 | - | map[Shift+NumLock+LevelThree+LevelFive]= 4; | |
| 979 | - | map[Lock]= 2; | |
| 980 | - | map[Lock+LevelThree]= 3; | |
| 981 | - | map[Shift+Lock+LevelThree]= 4; | |
| 982 | - | map[Lock+LevelFive]= 5; | |
| 983 | - | map[Shift+Lock+LevelFive]= 6; | |
| 984 | - | map[Lock+LevelThree+LevelFive]= 7; | |
| 985 | - | map[Shift+Lock+LevelThree+LevelFive]= 8; | |
| 986 | - | map[Lock+NumLock]= 5; | |
| 987 | - | map[Shift+Lock+NumLock]= 6; | |
| 988 | - | map[Lock+NumLock+LevelThree]= 7; | |
| 989 | - | map[Shift+Lock+NumLock+LevelThree]= 8; | |
| 990 | - | map[Lock+NumLock+LevelFive]= 2; | |
| 991 | - | map[Lock+NumLock+LevelThree+LevelFive]= 3; | |
| 992 | - | map[Shift+Lock+NumLock+LevelThree+LevelFive]= 4; | |
| 993 | - | level_name[1]= "Base"; | |
| 994 | - | level_name[2]= "Shift"; | |
| 995 | - | level_name[3]= "AltGr"; | |
| 996 | - | level_name[4]= "Shift AltGr"; | |
| 997 | - | level_name[5]= "X"; | |
| 998 | - | level_name[6]= "X Shift"; | |
| 999 | - | level_name[7]= "X AltGr"; | |
| 1000 | - | level_name[8]= "X Shift AltGr"; | |
| 1001 | - | }; | |
| 1002 | - | type "EIGHT_LEVEL_BY_CTRL" { | |
| 1003 | - | modifiers= Shift+Lock+Control+LevelThree; | |
| 1004 | - | map[Shift]= 2; | |
| 1005 | - | map[Lock]= 2; | |
| 1006 | - | map[LevelThree]= 3; | |
| 1007 | - | map[Shift+LevelThree]= 4; | |
| 1008 | - | map[Lock+LevelThree]= 4; | |
| 1009 | - | map[Shift+Lock+LevelThree]= 3; | |
| 1010 | - | map[Control]= 5; | |
| 1011 | - | map[Shift+Control]= 6; | |
| 1012 | - | map[Lock+Control]= 6; | |
| 1013 | - | map[Shift+Lock+Control]= 5; | |
| 1014 | - | map[Control+LevelThree]= 7; | |
| 1015 | - | map[Shift+Control+LevelThree]= 8; | |
| 1016 | - | map[Lock+Control+LevelThree]= 8; | |
| 1017 | - | map[Shift+Lock+Control+LevelThree]= 7; | |
| 1018 | - | level_name[1]= "Base"; | |
| 1019 | - | level_name[2]= "Shift"; | |
| 1020 | - | level_name[3]= "Level3"; | |
| 1021 | - | level_name[4]= "Shift Level3"; | |
| 1022 | - | level_name[5]= "Ctrl"; | |
| 1023 | - | level_name[6]= "Shift Ctrl"; | |
| 1024 | - | level_name[7]= "Level3 Ctrl"; | |
| 1025 | - | level_name[8]= "Shift Level3 Ctrl"; | |
| 1026 | - | }; | |
| 1027 | - | type "KEYPAD" { | |
| 1028 | - | modifiers= NumLock; | |
| 1029 | - | map[NumLock]= 2; | |
| 1030 | - | level_name[1]= "Base"; | |
| 1031 | - | level_name[2]= "Number"; | |
| 1032 | - | }; | |
| 1033 | - | type "SHIFT+ALT" { | |
| 1034 | - | modifiers= Shift+Alt; | |
| 1035 | - | map[Shift+Alt]= 2; | |
| 1036 | - | level_name[1]= "Base"; | |
| 1037 | - | level_name[2]= "Shift+Alt"; | |
| 1038 | - | }; | |
| 1039 | - | }; | |
| 1040 | - | ||
| 1041 | - | xkb_compatibility "complete" { | |
| 1042 | - | virtual_modifiers NumLock,Alt,LevelThree,Super,LevelFive,Meta,Hyper=0x4000,ScrollLock=0x8000; | |
| 1043 | - | ||
| 1044 | - | interpret.useModMapMods= AnyLevel; | |
| 1045 | - | interpret.repeat= False; | |
| 1046 | - | interpret ISO_Level2_Latch+Exactly(Shift) { | |
| 1047 | - | useModMapMods=level1; | |
| 1048 | - | action= LatchMods(modifiers=Shift,clearLocks,latchToLock); | |
| 1049 | - | }; | |
| 1050 | - | interpret Shift_Lock+AnyOf(Shift+Lock) { | |
| 1051 | - | action= LockMods(modifiers=Shift); | |
| 1052 | - | }; | |
| 1053 | - | interpret Num_Lock+AnyOf(all) { | |
| 1054 | - | virtualModifier= NumLock; | |
| 1055 | - | action= LockMods(modifiers=NumLock); | |
| 1056 | - | }; | |
| 1057 | - | interpret ISO_Level3_Shift+AnyOf(all) { | |
| 1058 | - | virtualModifier= LevelThree; | |
| 1059 | - | useModMapMods=level1; | |
| 1060 | - | action= SetMods(modifiers=LevelThree,clearLocks); | |
| 1061 | - | }; | |
| 1062 | - | interpret ISO_Level3_Latch+AnyOf(all) { | |
| 1063 | - | virtualModifier= LevelThree; | |
| 1064 | - | useModMapMods=level1; | |
| 1065 | - | action= LatchMods(modifiers=LevelThree,clearLocks,latchToLock); | |
| 1066 | - | }; | |
| 1067 | - | interpret ISO_Level3_Lock+AnyOf(all) { | |
| 1068 | - | virtualModifier= LevelThree; | |
| 1069 | - | useModMapMods=level1; | |
| 1070 | - | action= LockMods(modifiers=LevelThree); | |
| 1071 | - | }; | |
| 1072 | - | interpret Alt_L+AnyOf(all) { | |
| 1073 | - | virtualModifier= Alt; | |
| 1074 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1075 | - | }; | |
| 1076 | - | interpret Alt_R+AnyOf(all) { | |
| 1077 | - | virtualModifier= Alt; | |
| 1078 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1079 | - | }; | |
| 1080 | - | interpret Meta_L+AnyOf(all) { | |
| 1081 | - | virtualModifier= Meta; | |
| 1082 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1083 | - | }; | |
| 1084 | - | interpret Meta_R+AnyOf(all) { | |
| 1085 | - | virtualModifier= Meta; | |
| 1086 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1087 | - | }; | |
| 1088 | - | interpret Super_L+AnyOf(all) { | |
| 1089 | - | virtualModifier= Super; | |
| 1090 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1091 | - | }; | |
| 1092 | - | interpret Super_R+AnyOf(all) { | |
| 1093 | - | virtualModifier= Super; | |
| 1094 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1095 | - | }; | |
| 1096 | - | interpret Hyper_L+AnyOf(all) { | |
| 1097 | - | virtualModifier= Hyper; | |
| 1098 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1099 | - | }; | |
| 1100 | - | interpret Hyper_R+AnyOf(all) { | |
| 1101 | - | virtualModifier= Hyper; | |
| 1102 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1103 | - | }; | |
| 1104 | - | interpret Scroll_Lock+AnyOf(all) { | |
| 1105 | - | virtualModifier= ScrollLock; | |
| 1106 | - | action= LockMods(modifiers=modMapMods); | |
| 1107 | - | }; | |
| 1108 | - | interpret ISO_Level5_Shift+AnyOf(all) { | |
| 1109 | - | virtualModifier= LevelFive; | |
| 1110 | - | useModMapMods=level1; | |
| 1111 | - | action= SetMods(modifiers=LevelFive,clearLocks); | |
| 1112 | - | }; | |
| 1113 | - | interpret ISO_Level5_Latch+AnyOf(all) { | |
| 1114 | - | virtualModifier= LevelFive; | |
| 1115 | - | useModMapMods=level1; | |
| 1116 | - | action= LatchMods(modifiers=LevelFive,clearLocks,latchToLock); | |
| 1117 | - | }; | |
| 1118 | - | interpret ISO_Level5_Lock+AnyOf(all) { | |
| 1119 | - | virtualModifier= LevelFive; | |
| 1120 | - | useModMapMods=level1; | |
| 1121 | - | action= LockMods(modifiers=LevelFive); | |
| 1122 | - | }; | |
| 1123 | - | interpret ISO_Group_Shift+AnyOfOrNone(all) { | |
| 1124 | - | action= SetGroup(group=+1); | |
| 1125 | - | }; | |
| 1126 | - | interpret ISO_Level3_Shift+AnyOfOrNone(all) { | |
| 1127 | - | action= SetMods(modifiers=LevelThree,clearLocks); | |
| 1128 | - | }; | |
| 1129 | - | interpret ISO_Level3_Latch+AnyOfOrNone(all) { | |
| 1130 | - | action= LatchMods(modifiers=LevelThree,clearLocks,latchToLock); | |
| 1131 | - | }; | |
| 1132 | - | interpret ISO_Level3_Lock+AnyOfOrNone(all) { | |
| 1133 | - | action= LockMods(modifiers=LevelThree); | |
| 1134 | - | }; | |
| 1135 | - | interpret ISO_Group_Latch+AnyOfOrNone(all) { | |
| 1136 | - | action= LatchGroup(group=2); | |
| 1137 | - | }; | |
| 1138 | - | interpret ISO_Next_Group+AnyOfOrNone(all) { | |
| 1139 | - | action= LockGroup(group=+1); | |
| 1140 | - | }; | |
| 1141 | - | interpret ISO_Prev_Group+AnyOfOrNone(all) { | |
| 1142 | - | action= LockGroup(group=-1); | |
| 1143 | - | }; | |
| 1144 | - | interpret ISO_First_Group+AnyOfOrNone(all) { | |
| 1145 | - | action= LockGroup(group=1); | |
| 1146 | - | }; | |
| 1147 | - | interpret ISO_Last_Group+AnyOfOrNone(all) { | |
| 1148 | - | action= LockGroup(group=2); | |
| 1149 | - | }; | |
| 1150 | - | interpret KP_1+AnyOfOrNone(all) { | |
| 1151 | - | repeat= True; | |
| 1152 | - | action= MovePtr(x=-1,y=+1); | |
| 1153 | - | }; | |
| 1154 | - | interpret KP_End+AnyOfOrNone(all) { | |
| 1155 | - | repeat= True; | |
| 1156 | - | action= MovePtr(x=-1,y=+1); | |
| 1157 | - | }; | |
| 1158 | - | interpret KP_2+AnyOfOrNone(all) { | |
| 1159 | - | repeat= True; | |
| 1160 | - | action= MovePtr(x=+0,y=+1); | |
| 1161 | - | }; | |
| 1162 | - | interpret KP_Down+AnyOfOrNone(all) { | |
| 1163 | - | repeat= True; | |
| 1164 | - | action= MovePtr(x=+0,y=+1); | |
| 1165 | - | }; | |
| 1166 | - | interpret KP_3+AnyOfOrNone(all) { | |
| 1167 | - | repeat= True; | |
| 1168 | - | action= MovePtr(x=+1,y=+1); | |
| 1169 | - | }; | |
| 1170 | - | interpret KP_Next+AnyOfOrNone(all) { | |
| 1171 | - | repeat= True; | |
| 1172 | - | action= MovePtr(x=+1,y=+1); | |
| 1173 | - | }; | |
| 1174 | - | interpret KP_4+AnyOfOrNone(all) { | |
| 1175 | - | repeat= True; | |
| 1176 | - | action= MovePtr(x=-1,y=+0); | |
| 1177 | - | }; | |
| 1178 | - | interpret KP_Left+AnyOfOrNone(all) { | |
| 1179 | - | repeat= True; | |
| 1180 | - | action= MovePtr(x=-1,y=+0); | |
| 1181 | - | }; | |
| 1182 | - | interpret KP_6+AnyOfOrNone(all) { | |
| 1183 | - | repeat= True; | |
| 1184 | - | action= MovePtr(x=+1,y=+0); | |
| 1185 | - | }; | |
| 1186 | - | interpret KP_Right+AnyOfOrNone(all) { | |
| 1187 | - | repeat= True; | |
| 1188 | - | action= MovePtr(x=+1,y=+0); | |
| 1189 | - | }; | |
| 1190 | - | interpret KP_7+AnyOfOrNone(all) { | |
| 1191 | - | repeat= True; | |
| 1192 | - | action= MovePtr(x=-1,y=-1); | |
| 1193 | - | }; | |
| 1194 | - | interpret KP_Home+AnyOfOrNone(all) { | |
| 1195 | - | repeat= True; | |
| 1196 | - | action= MovePtr(x=-1,y=-1); | |
| 1197 | - | }; | |
| 1198 | - | interpret KP_8+AnyOfOrNone(all) { | |
| 1199 | - | repeat= True; | |
| 1200 | - | action= MovePtr(x=+0,y=-1); | |
| 1201 | - | }; | |
| 1202 | - | interpret KP_Up+AnyOfOrNone(all) { | |
| 1203 | - | repeat= True; | |
| 1204 | - | action= MovePtr(x=+0,y=-1); | |
| 1205 | - | }; | |
| 1206 | - | interpret KP_9+AnyOfOrNone(all) { | |
| 1207 | - | repeat= True; | |
| 1208 | - | action= MovePtr(x=+1,y=-1); | |
| 1209 | - | }; | |
| 1210 | - | interpret KP_Prior+AnyOfOrNone(all) { | |
| 1211 | - | repeat= True; | |
| 1212 | - | action= MovePtr(x=+1,y=-1); | |
| 1213 | - | }; | |
| 1214 | - | interpret KP_5+AnyOfOrNone(all) { | |
| 1215 | - | repeat= True; | |
| 1216 | - | action= PtrBtn(button=default); | |
| 1217 | - | }; | |
| 1218 | - | interpret KP_Begin+AnyOfOrNone(all) { | |
| 1219 | - | repeat= True; | |
| 1220 | - | action= PtrBtn(button=default); | |
| 1221 | - | }; | |
| 1222 | - | interpret KP_F2+AnyOfOrNone(all) { | |
| 1223 | - | repeat= True; | |
| 1224 | - | action= SetPtrDflt(affect=button,button=1); | |
| 1225 | - | }; | |
| 1226 | - | interpret KP_Divide+AnyOfOrNone(all) { | |
| 1227 | - | repeat= True; | |
| 1228 | - | action= SetPtrDflt(affect=button,button=1); | |
| 1229 | - | }; | |
| 1230 | - | interpret KP_F3+AnyOfOrNone(all) { | |
| 1231 | - | repeat= True; | |
| 1232 | - | action= SetPtrDflt(affect=button,button=2); | |
| 1233 | - | }; | |
| 1234 | - | interpret KP_Multiply+AnyOfOrNone(all) { | |
| 1235 | - | repeat= True; | |
| 1236 | - | action= SetPtrDflt(affect=button,button=2); | |
| 1237 | - | }; | |
| 1238 | - | interpret KP_F4+AnyOfOrNone(all) { | |
| 1239 | - | repeat= True; | |
| 1240 | - | action= SetPtrDflt(affect=button,button=3); | |
| 1241 | - | }; | |
| 1242 | - | interpret KP_Subtract+AnyOfOrNone(all) { | |
| 1243 | - | repeat= True; | |
| 1244 | - | action= SetPtrDflt(affect=button,button=3); | |
| 1245 | - | }; | |
| 1246 | - | interpret KP_Separator+AnyOfOrNone(all) { | |
| 1247 | - | repeat= True; | |
| 1248 | - | action= PtrBtn(button=default,count=2); | |
| 1249 | - | }; | |
| 1250 | - | interpret KP_Add+AnyOfOrNone(all) { | |
| 1251 | - | repeat= True; | |
| 1252 | - | action= PtrBtn(button=default,count=2); | |
| 1253 | - | }; | |
| 1254 | - | interpret KP_0+AnyOfOrNone(all) { | |
| 1255 | - | repeat= True; | |
| 1256 | - | action= LockPtrBtn(button=default,affect=lock); | |
| 1257 | - | }; | |
| 1258 | - | interpret KP_Insert+AnyOfOrNone(all) { | |
| 1259 | - | repeat= True; | |
| 1260 | - | action= LockPtrBtn(button=default,affect=lock); | |
| 1261 | - | }; | |
| 1262 | - | interpret KP_Decimal+AnyOfOrNone(all) { | |
| 1263 | - | repeat= True; | |
| 1264 | - | action= LockPtrBtn(button=default,affect=unlock); | |
| 1265 | - | }; | |
| 1266 | - | interpret KP_Delete+AnyOfOrNone(all) { | |
| 1267 | - | repeat= True; | |
| 1268 | - | action= LockPtrBtn(button=default,affect=unlock); | |
| 1269 | - | }; | |
| 1270 | - | interpret F25+AnyOfOrNone(all) { | |
| 1271 | - | repeat= True; | |
| 1272 | - | action= SetPtrDflt(affect=button,button=1); | |
| 1273 | - | }; | |
| 1274 | - | interpret F26+AnyOfOrNone(all) { | |
| 1275 | - | repeat= True; | |
| 1276 | - | action= SetPtrDflt(affect=button,button=2); | |
| 1277 | - | }; | |
| 1278 | - | interpret F27+AnyOfOrNone(all) { | |
| 1279 | - | repeat= True; | |
| 1280 | - | action= MovePtr(x=-1,y=-1); | |
| 1281 | - | }; | |
| 1282 | - | interpret F29+AnyOfOrNone(all) { | |
| 1283 | - | repeat= True; | |
| 1284 | - | action= MovePtr(x=+1,y=-1); | |
| 1285 | - | }; | |
| 1286 | - | interpret F31+AnyOfOrNone(all) { | |
| 1287 | - | repeat= True; | |
| 1288 | - | action= PtrBtn(button=default); | |
| 1289 | - | }; | |
| 1290 | - | interpret F33+AnyOfOrNone(all) { | |
| 1291 | - | repeat= True; | |
| 1292 | - | action= MovePtr(x=-1,y=+1); | |
| 1293 | - | }; | |
| 1294 | - | interpret F35+AnyOfOrNone(all) { | |
| 1295 | - | repeat= True; | |
| 1296 | - | action= MovePtr(x=+1,y=+1); | |
| 1297 | - | }; | |
| 1298 | - | interpret Pointer_Button_Dflt+AnyOfOrNone(all) { | |
| 1299 | - | action= PtrBtn(button=default); | |
| 1300 | - | }; | |
| 1301 | - | interpret Pointer_Button1+AnyOfOrNone(all) { | |
| 1302 | - | action= PtrBtn(button=1); | |
| 1303 | - | }; | |
| 1304 | - | interpret Pointer_Button2+AnyOfOrNone(all) { | |
| 1305 | - | action= PtrBtn(button=2); | |
| 1306 | - | }; | |
| 1307 | - | interpret Pointer_Button3+AnyOfOrNone(all) { | |
| 1308 | - | action= PtrBtn(button=3); | |
| 1309 | - | }; | |
| 1310 | - | interpret Pointer_DblClick_Dflt+AnyOfOrNone(all) { | |
| 1311 | - | action= PtrBtn(button=default,count=2); | |
| 1312 | - | }; | |
| 1313 | - | interpret Pointer_DblClick1+AnyOfOrNone(all) { | |
| 1314 | - | action= PtrBtn(button=1,count=2); | |
| 1315 | - | }; | |
| 1316 | - | interpret Pointer_DblClick2+AnyOfOrNone(all) { | |
| 1317 | - | action= PtrBtn(button=2,count=2); | |
| 1318 | - | }; | |
| 1319 | - | interpret Pointer_DblClick3+AnyOfOrNone(all) { | |
| 1320 | - | action= PtrBtn(button=3,count=2); | |
| 1321 | - | }; | |
| 1322 | - | interpret Pointer_Drag_Dflt+AnyOfOrNone(all) { | |
| 1323 | - | action= LockPtrBtn(button=default,affect=both); | |
| 1324 | - | }; | |
| 1325 | - | interpret Pointer_Drag1+AnyOfOrNone(all) { | |
| 1326 | - | action= LockPtrBtn(button=1,affect=both); | |
| 1327 | - | }; | |
| 1328 | - | interpret Pointer_Drag2+AnyOfOrNone(all) { | |
| 1329 | - | action= LockPtrBtn(button=2,affect=both); | |
| 1330 | - | }; | |
| 1331 | - | interpret Pointer_Drag3+AnyOfOrNone(all) { | |
| 1332 | - | action= LockPtrBtn(button=3,affect=both); | |
| 1333 | - | }; | |
| 1334 | - | interpret Pointer_EnableKeys+AnyOfOrNone(all) { | |
| 1335 | - | action= LockControls(controls=MouseKeys); | |
| 1336 | - | }; | |
| 1337 | - | interpret Pointer_Accelerate+AnyOfOrNone(all) { | |
| 1338 | - | action= LockControls(controls=MouseKeysAccel); | |
| 1339 | - | }; | |
| 1340 | - | interpret Pointer_DfltBtnNext+AnyOfOrNone(all) { | |
| 1341 | - | action= SetPtrDflt(affect=button,button=+1); | |
| 1342 | - | }; | |
| 1343 | - | interpret Pointer_DfltBtnPrev+AnyOfOrNone(all) { | |
| 1344 | - | action= SetPtrDflt(affect=button,button=-1); | |
| 1345 | - | }; | |
| 1346 | - | interpret AccessX_Enable+AnyOfOrNone(all) { | |
| 1347 | - | action= LockControls(controls=AccessXKeys); | |
| 1348 | - | }; | |
| 1349 | - | interpret AccessX_Feedback_Enable+AnyOfOrNone(all) { | |
| 1350 | - | action= LockControls(controls=AccessXFeedback); | |
| 1351 | - | }; | |
| 1352 | - | interpret RepeatKeys_Enable+AnyOfOrNone(all) { | |
| 1353 | - | action= LockControls(controls=RepeatKeys); | |
| 1354 | - | }; | |
| 1355 | - | interpret SlowKeys_Enable+AnyOfOrNone(all) { | |
| 1356 | - | action= LockControls(controls=SlowKeys); | |
| 1357 | - | }; | |
| 1358 | - | interpret BounceKeys_Enable+AnyOfOrNone(all) { | |
| 1359 | - | action= LockControls(controls=BounceKeys); | |
| 1360 | - | }; | |
| 1361 | - | interpret StickyKeys_Enable+AnyOfOrNone(all) { | |
| 1362 | - | action= LockControls(controls=StickyKeys); | |
| 1363 | - | }; | |
| 1364 | - | interpret MouseKeys_Enable+AnyOfOrNone(all) { | |
| 1365 | - | action= LockControls(controls=MouseKeys); | |
| 1366 | - | }; | |
| 1367 | - | interpret MouseKeys_Accel_Enable+AnyOfOrNone(all) { | |
| 1368 | - | action= LockControls(controls=MouseKeysAccel); | |
| 1369 | - | }; | |
| 1370 | - | interpret Overlay1_Enable+AnyOfOrNone(all) { | |
| 1371 | - | action= LockControls(controls=none); | |
| 1372 | - | }; | |
| 1373 | - | interpret Overlay2_Enable+AnyOfOrNone(all) { | |
| 1374 | - | action= LockControls(controls=none); | |
| 1375 | - | }; | |
| 1376 | - | interpret AudibleBell_Enable+AnyOfOrNone(all) { | |
| 1377 | - | action= LockControls(controls=AudibleBell); | |
| 1378 | - | }; | |
| 1379 | - | interpret Terminate_Server+AnyOfOrNone(all) { | |
| 1380 | - | action= Terminate(); | |
| 1381 | - | }; | |
| 1382 | - | interpret Alt_L+AnyOfOrNone(all) { | |
| 1383 | - | action= SetMods(modifiers=Alt,clearLocks); | |
| 1384 | - | }; | |
| 1385 | - | interpret Alt_R+AnyOfOrNone(all) { | |
| 1386 | - | action= SetMods(modifiers=Alt,clearLocks); | |
| 1387 | - | }; | |
| 1388 | - | interpret Meta_L+AnyOfOrNone(all) { | |
| 1389 | - | action= SetMods(modifiers=Meta,clearLocks); | |
| 1390 | - | }; | |
| 1391 | - | interpret Meta_R+AnyOfOrNone(all) { | |
| 1392 | - | action= SetMods(modifiers=Meta,clearLocks); | |
| 1393 | - | }; | |
| 1394 | - | interpret Super_L+AnyOfOrNone(all) { | |
| 1395 | - | action= SetMods(modifiers=Super,clearLocks); | |
| 1396 | - | }; | |
| 1397 | - | interpret Super_R+AnyOfOrNone(all) { | |
| 1398 | - | action= SetMods(modifiers=Super,clearLocks); | |
| 1399 | - | }; | |
| 1400 | - | interpret Hyper_L+AnyOfOrNone(all) { | |
| 1401 | - | action= SetMods(modifiers=Hyper,clearLocks); | |
| 1402 | - | }; | |
| 1403 | - | interpret Hyper_R+AnyOfOrNone(all) { | |
| 1404 | - | action= SetMods(modifiers=Hyper,clearLocks); | |
| 1405 | - | }; | |
| 1406 | - | interpret Shift_L+AnyOfOrNone(all) { | |
| 1407 | - | action= SetMods(modifiers=Shift,clearLocks); | |
| 1408 | - | }; | |
| 1409 | - | interpret Shift_R+AnyOfOrNone(all) { | |
| 1410 | - | action= SetMods(modifiers=Shift,clearLocks); | |
| 1411 | - | }; | |
| 1412 | - | interpret XF86Switch_VT_1+AnyOfOrNone(all) { | |
| 1413 | - | repeat= True; | |
| 1414 | - | action= SwitchScreen(screen=1,!same); | |
| 1415 | - | }; | |
| 1416 | - | interpret XF86Switch_VT_2+AnyOfOrNone(all) { | |
| 1417 | - | repeat= True; | |
| 1418 | - | action= SwitchScreen(screen=2,!same); | |
| 1419 | - | }; | |
| 1420 | - | interpret XF86Switch_VT_3+AnyOfOrNone(all) { | |
| 1421 | - | repeat= True; | |
| 1422 | - | action= SwitchScreen(screen=3,!same); | |
| 1423 | - | }; | |
| 1424 | - | interpret XF86Switch_VT_4+AnyOfOrNone(all) { | |
| 1425 | - | repeat= True; | |
| 1426 | - | action= SwitchScreen(screen=4,!same); | |
| 1427 | - | }; | |
| 1428 | - | interpret XF86Switch_VT_5+AnyOfOrNone(all) { | |
| 1429 | - | repeat= True; | |
| 1430 | - | action= SwitchScreen(screen=5,!same); | |
| 1431 | - | }; | |
| 1432 | - | interpret XF86Switch_VT_6+AnyOfOrNone(all) { | |
| 1433 | - | repeat= True; | |
| 1434 | - | action= SwitchScreen(screen=6,!same); | |
| 1435 | - | }; | |
| 1436 | - | interpret XF86Switch_VT_7+AnyOfOrNone(all) { | |
| 1437 | - | repeat= True; | |
| 1438 | - | action= SwitchScreen(screen=7,!same); | |
| 1439 | - | }; | |
| 1440 | - | interpret XF86Switch_VT_8+AnyOfOrNone(all) { | |
| 1441 | - | repeat= True; | |
| 1442 | - | action= SwitchScreen(screen=8,!same); | |
| 1443 | - | }; | |
| 1444 | - | interpret XF86Switch_VT_9+AnyOfOrNone(all) { | |
| 1445 | - | repeat= True; | |
| 1446 | - | action= SwitchScreen(screen=9,!same); | |
| 1447 | - | }; | |
| 1448 | - | interpret XF86Switch_VT_10+AnyOfOrNone(all) { | |
| 1449 | - | repeat= True; | |
| 1450 | - | action= SwitchScreen(screen=10,!same); | |
| 1451 | - | }; | |
| 1452 | - | interpret XF86Switch_VT_11+AnyOfOrNone(all) { | |
| 1453 | - | repeat= True; | |
| 1454 | - | action= SwitchScreen(screen=11,!same); | |
| 1455 | - | }; | |
| 1456 | - | interpret XF86Switch_VT_12+AnyOfOrNone(all) { | |
| 1457 | - | repeat= True; | |
| 1458 | - | action= SwitchScreen(screen=12,!same); | |
| 1459 | - | }; | |
| 1460 | - | interpret XF86LogGrabInfo+AnyOfOrNone(all) { | |
| 1461 | - | repeat= True; | |
| 1462 | - | action= Private(type=0x86,data[0]=0x50,data[1]=0x72,data[2]=0x47,data[3]=0x72,data[4]=0x62,data[5]=0x73,data[6]=0x00); | |
| 1463 | - | }; | |
| 1464 | - | interpret XF86LogWindowTree+AnyOfOrNone(all) { | |
| 1465 | - | repeat= True; | |
| 1466 | - | action= Private(type=0x86,data[0]=0x50,data[1]=0x72,data[2]=0x57,data[3]=0x69,data[4]=0x6e,data[5]=0x73,data[6]=0x00); | |
| 1467 | - | }; | |
| 1468 | - | interpret XF86Next_VMode+AnyOfOrNone(all) { | |
| 1469 | - | repeat= True; | |
| 1470 | - | action= Private(type=0x86,data[0]=0x2b,data[1]=0x56,data[2]=0x4d,data[3]=0x6f,data[4]=0x64,data[5]=0x65,data[6]=0x00); | |
| 1471 | - | }; | |
| 1472 | - | interpret XF86Prev_VMode+AnyOfOrNone(all) { | |
| 1473 | - | repeat= True; | |
| 1474 | - | action= Private(type=0x86,data[0]=0x2d,data[1]=0x56,data[2]=0x4d,data[3]=0x6f,data[4]=0x64,data[5]=0x65,data[6]=0x00); | |
| 1475 | - | }; | |
| 1476 | - | interpret ISO_Level5_Shift+AnyOfOrNone(all) { | |
| 1477 | - | action= SetMods(modifiers=LevelFive,clearLocks); | |
| 1478 | - | }; | |
| 1479 | - | interpret ISO_Level5_Latch+AnyOfOrNone(all) { | |
| 1480 | - | action= LatchMods(modifiers=LevelFive,clearLocks,latchToLock); | |
| 1481 | - | }; | |
| 1482 | - | interpret ISO_Level5_Lock+AnyOfOrNone(all) { | |
| 1483 | - | action= LockMods(modifiers=LevelFive); | |
| 1484 | - | }; | |
| 1485 | - | interpret Caps_Lock+AnyOfOrNone(all) { | |
| 1486 | - | action= LockMods(modifiers=Lock); | |
| 1487 | - | }; | |
| 1488 | - | interpret Any+Exactly(Lock) { | |
| 1489 | - | action= LockMods(modifiers=Lock); | |
| 1490 | - | }; | |
| 1491 | - | interpret Any+AnyOf(all) { | |
| 1492 | - | action= SetMods(modifiers=modMapMods,clearLocks); | |
| 1493 | - | }; | |
| 1494 | - | indicator "Caps Lock" { | |
| 1495 | - | whichModState= locked; | |
| 1496 | - | modifiers= Lock; | |
| 1497 | - | }; | |
| 1498 | - | indicator "Num Lock" { | |
| 1499 | - | whichModState= locked; | |
| 1500 | - | modifiers= NumLock; | |
| 1501 | - | }; | |
| 1502 | - | indicator "Scroll Lock" { | |
| 1503 | - | whichModState= locked; | |
| 1504 | - | modifiers= ScrollLock; | |
| 1505 | - | }; | |
| 1506 | - | indicator "Shift Lock" { | |
| 1507 | - | whichModState= locked; | |
| 1508 | - | modifiers= Shift; | |
| 1509 | - | }; | |
| 1510 | - | indicator "Group 2" { | |
| 1511 | - | groups= 0xfffffffe; | |
| 1512 | - | }; | |
| 1513 | - | indicator "Mouse Keys" { | |
| 1514 | - | controls= MouseKeys; | |
| 1515 | - | }; | |
| 1516 | - | }; | |
| 1517 | - | ||
| 1518 | - | xkb_symbols "pc_fr_inet(evdev)" { | |
| 1519 | - | name[1]="French"; | |
| 1520 | - | ||
| 1521 | - | key <ESC> { [ Escape ] }; | |
| 1522 | - | key <AE01> { [ ampersand, 1, onesuperior, exclamdown ] }; | |
| 1523 | - | key <AE02> { [ eacute, 2, asciitilde, oneeighth ] }; | |
| 1524 | - | key <AE03> { [ quotedbl, 3, numbersign, sterling ] }; | |
| 1525 | - | key <AE04> { [ apostrophe, 4, braceleft, dollar ] }; | |
| 1526 | - | key <AE05> { [ parenleft, 5, bracketleft, threeeighths ] }; | |
| 1527 | - | key <AE06> { [ minus, 6, bar, fiveeighths ] }; | |
| 1528 | - | key <AE07> { [ egrave, 7, grave, seveneighths ] }; | |
| 1529 | - | key <AE08> { [ underscore, 8, backslash, trademark ] }; | |
| 1530 | - | key <AE09> { [ ccedilla, 9, asciicircum, plusminus ] }; | |
| 1531 | - | key <AE10> { [ agrave, 0, at, degree ] }; | |
| 1532 | - | key <AE11> { [ parenright, degree, bracketright, questiondown ] }; | |
| 1533 | - | key <AE12> { [ equal, plus, braceright, dead_ogonek ] }; | |
| 1534 | - | key <BKSP> { [ BackSpace, BackSpace ] }; | |
| 1535 | - | key <TAB> { [ Tab, ISO_Left_Tab ] }; | |
| 1536 | - | key <AD01> { [ a, A, ae, AE ] }; | |
| 1537 | - | key <AD02> { [ z, Z, guillemotleft, less ] }; | |
| 1538 | - | key <AD03> { [ e, E, EuroSign, cent ] }; | |
| 1539 | - | key <AD04> { [ r, R, paragraph, registered ] }; | |
| 1540 | - | key <AD05> { [ t, T, tslash, Tslash ] }; | |
| 1541 | - | key <AD06> { [ y, Y, leftarrow, yen ] }; | |
| 1542 | - | key <AD07> { [ u, U, downarrow, uparrow ] }; | |
| 1543 | - | key <AD08> { [ i, I, rightarrow, idotless ] }; | |
| 1544 | - | key <AD09> { [ o, O, oslash, Oslash ] }; | |
| 1545 | - | key <AD10> { [ p, P, thorn, THORN ] }; | |
| 1546 | - | key <AD11> { [ dead_circumflex, dead_diaeresis, dead_diaeresis, dead_abovering ] }; | |
| 1547 | - | key <AD12> { [ dollar, sterling, currency, dead_macron ] }; | |
| 1548 | - | key <RTRN> { [ Return ] }; | |
| 1549 | - | key <LCTL> { [ Control_L ] }; | |
| 1550 | - | key <AC01> { [ q, Q, at, Greek_OMEGA ] }; | |
| 1551 | - | key <AC02> { [ s, S, ssharp, SSHARP ] }; | |
| 1552 | - | key <AC03> { [ d, D, eth, ETH ] }; | |
| 1553 | - | key <AC04> { [ f, F, dstroke, ordfeminine ] }; | |
| 1554 | - | key <AC05> { [ g, G, eng, ENG ] }; | |
| 1555 | - | key <AC06> { [ h, H, hstroke, Hstroke ] }; | |
| 1556 | - | key <AC07> { [ j, J, dead_hook, dead_horn ] }; | |
| 1557 | - | key <AC08> { [ k, K, kra, ampersand ] }; | |
| 1558 | - | key <AC09> { [ l, L, lstroke, Lstroke ] }; | |
| 1559 | - | key <AC10> { [ m, M, mu, masculine ] }; | |
| 1560 | - | key <AC11> { [ ugrave, percent, dead_circumflex, dead_caron ] }; | |
| 1561 | - | key <TLDE> { [ twosuperior, asciitilde, notsign, notsign ] }; | |
| 1562 | - | key <LFSH> { [ Shift_L ] }; | |
| 1563 | - | key <BKSL> { [ asterisk, mu, dead_grave, dead_breve ] }; | |
| 1564 | - | key <AB01> { [ w, W, lstroke, Lstroke ] }; | |
| 1565 | - | key <AB02> { [ x, X, guillemotright, greater ] }; | |
| 1566 | - | key <AB03> { [ c, C, cent, copyright ] }; | |
| 1567 | - | key <AB04> { [ v, V, doublelowquotemark, singlelowquotemark ] }; | |
| 1568 | - | key <AB05> { [ b, B, leftdoublequotemark, leftsinglequotemark ] }; | |
| 1569 | - | key <AB06> { [ n, N, rightdoublequotemark, rightsinglequotemark ] }; | |
| 1570 | - | key <AB07> { [ comma, question, dead_acute, dead_doubleacute ] }; | |
| 1571 | - | key <AB08> { [ semicolon, period, U2022, multiply ] }; | |
| 1572 | - | key <AB09> { [ colon, slash, periodcentered, division ] }; | |
| 1573 | - | key <AB10> { [ exclam, section, dead_belowdot, dead_abovedot ] }; | |
| 1574 | - | key <RTSH> { [ Shift_R ] }; | |
| 1575 | - | key <KPMU> { | |
| 1576 | - | type= "CTRL+ALT", | |
| 1577 | - | symbols[1]= [ KP_Multiply, KP_Multiply, KP_Multiply, KP_Multiply, XF86ClearGrab ] | |
| 1578 | - | }; | |
| 1579 | - | key <LALT> { [ Alt_L ] }; | |
| 1580 | - | key <SPCE> { [ space ] }; | |
| 1581 | - | key <CAPS> { [ Caps_Lock ] }; | |
| 1582 | - | key <FK01> { | |
| 1583 | - | type= "CTRL+ALT", | |
| 1584 | - | symbols[1]= [ F1, F1, F1, F1, XF86Switch_VT_1 ] | |
| 1585 | - | }; | |
| 1586 | - | key <FK02> { | |
| 1587 | - | type= "CTRL+ALT", | |
| 1588 | - | symbols[1]= [ F2, F2, F2, F2, XF86Switch_VT_2 ] | |
| 1589 | - | }; | |
| 1590 | - | key <FK03> { | |
| 1591 | - | type= "CTRL+ALT", | |
| 1592 | - | symbols[1]= [ F3, F3, F3, F3, XF86Switch_VT_3 ] | |
| 1593 | - | }; | |
| 1594 | - | key <FK04> { | |
| 1595 | - | type= "CTRL+ALT", | |
| 1596 | - | symbols[1]= [ F4, F4, F4, F4, XF86Switch_VT_4 ] | |
| 1597 | - | }; | |
| 1598 | - | key <FK05> { | |
| 1599 | - | type= "CTRL+ALT", | |
| 1600 | - | symbols[1]= [ F5, F5, F5, F5, XF86Switch_VT_5 ] | |
| 1601 | - | }; | |
| 1602 | - | key <FK06> { | |
| 1603 | - | type= "CTRL+ALT", | |
| 1604 | - | symbols[1]= [ F6, F6, F6, F6, XF86Switch_VT_6 ] | |
| 1605 | - | }; | |
| 1606 | - | key <FK07> { | |
| 1607 | - | type= "CTRL+ALT", | |
| 1608 | - | symbols[1]= [ F7, F7, F7, F7, XF86Switch_VT_7 ] | |
| 1609 | - | }; | |
| 1610 | - | key <FK08> { | |
| 1611 | - | type= "CTRL+ALT", | |
| 1612 | - | symbols[1]= [ F8, F8, F8, F8, XF86Switch_VT_8 ] | |
| 1613 | - | }; | |
| 1614 | - | key <FK09> { | |
| 1615 | - | type= "CTRL+ALT", | |
| 1616 | - | symbols[1]= [ F9, F9, F9, F9, XF86Switch_VT_9 ] | |
| 1617 | - | }; | |
| 1618 | - | key <FK10> { | |
| 1619 | - | type= "CTRL+ALT", | |
| 1620 | - | symbols[1]= [ F10, F10, F10, F10, XF86Switch_VT_10 ] | |
| 1621 | - | }; | |
| 1622 | - | key <NMLK> { [ Num_Lock ] }; | |
| 1623 | - | key <SCLK> { [ Scroll_Lock ] }; | |
| 1624 | - | key <KP7> { [ KP_Home, KP_7 ] }; | |
| 1625 | - | key <KP8> { [ KP_Up, KP_8 ] }; | |
| 1626 | - | key <KP9> { [ KP_Prior, KP_9 ] }; | |
| 1627 | - | key <KPSU> { | |
| 1628 | - | type= "CTRL+ALT", | |
| 1629 | - | symbols[1]= [ KP_Subtract, KP_Subtract, KP_Subtract, KP_Subtract, XF86Prev_VMode ] | |
| 1630 | - | }; | |
| 1631 | - | key <KP4> { [ KP_Left, KP_4 ] }; | |
| 1632 | - | key <KP5> { [ KP_Begin, KP_5 ] }; | |
| 1633 | - | key <KP6> { [ KP_Right, KP_6 ] }; | |
| 1634 | - | key <KPAD> { | |
| 1635 | - | type= "CTRL+ALT", | |
| 1636 | - | symbols[1]= [ KP_Add, KP_Add, KP_Add, KP_Add, XF86Next_VMode ] | |
| 1637 | - | }; | |
| 1638 | - | key <KP1> { [ KP_End, KP_1 ] }; | |
| 1639 | - | key <KP2> { [ KP_Down, KP_2 ] }; | |
| 1640 | - | key <KP3> { [ KP_Next, KP_3 ] }; | |
| 1641 | - | key <KP0> { [ KP_Insert, KP_0 ] }; | |
| 1642 | - | key <KPDL> { [ KP_Delete, KP_Decimal ] }; | |
| 1643 | - | key <LVL3> { [ ISO_Level3_Shift ] }; | |
| 1644 | - | key <ZEHA> { | |
| 1645 | - | type= "PC_CONTROL_SUPER_LEVEL2", | |
| 1646 | - | symbols[1]= [ F24, XF86TouchpadToggle ] | |
| 1647 | - | }; | |
| 1648 | - | key <LSGT> { [ less, greater, bar, brokenbar ] }; | |
| 1649 | - | key <FK11> { | |
| 1650 | - | type= "CTRL+ALT", | |
| 1651 | - | symbols[1]= [ F11, F11, F11, F11, XF86Switch_VT_11 ] | |
| 1652 | - | }; | |
| 1653 | - | key <FK12> { | |
| 1654 | - | type= "CTRL+ALT", | |
| 1655 | - | symbols[1]= [ F12, F12, F12, F12, XF86Switch_VT_12 ] | |
| 1656 | - | }; | |
| 1657 | - | key <KATA> { [ Katakana ] }; | |
| 1658 | - | key <HIRA> { [ Hiragana ] }; | |
| 1659 | - | key <HENK> { [ Henkan_Mode ] }; | |
| 1660 | - | key <HKTG> { [ Hiragana_Katakana ] }; | |
| 1661 | - | key <MUHE> { [ Muhenkan ] }; | |
| 1662 | - | key <KPEN> { [ KP_Enter ] }; | |
| 1663 | - | key <RCTL> { [ Control_R ] }; | |
| 1664 | - | key <KPDV> { | |
| 1665 | - | type= "CTRL+ALT", | |
| 1666 | - | symbols[1]= [ KP_Divide, KP_Divide, KP_Divide, KP_Divide, XF86Ungrab ] | |
| 1667 | - | }; | |
| 1668 | - | key <PRSC> { | |
| 1669 | - | type= "PC_ALT_LEVEL2", | |
| 1670 | - | symbols[1]= [ Print, Sys_Req ] | |
| 1671 | - | }; | |
| 1672 | - | key <RALT> { | |
| 1673 | - | type= "ONE_LEVEL", | |
| 1674 | - | symbols[1]= [ ISO_Level3_Shift ] | |
| 1675 | - | }; | |
| 1676 | - | key <LNFD> { [ Linefeed ] }; | |
| 1677 | - | key <HOME> { [ Home ] }; | |
| 1678 | - | key <UP> { [ Up ] }; | |
| 1679 | - | key <PGUP> { [ Prior ] }; | |
| 1680 | - | key <LEFT> { [ Left ] }; | |
| 1681 | - | key <RGHT> { [ Right ] }; | |
| 1682 | - | key <END> { [ End ] }; | |
| 1683 | - | key <DOWN> { [ Down ] }; | |
| 1684 | - | key <PGDN> { [ Next ] }; | |
| 1685 | - | key <INS> { [ Insert ] }; | |
| 1686 | - | key <DELE> { [ Delete ] }; | |
| 1687 | - | key <MUTE> { [ XF86AudioMute ] }; | |
| 1688 | - | key <VOL-> { [ XF86AudioLowerVolume ] }; | |
| 1689 | - | key <VOL+> { [ XF86AudioRaiseVolume ] }; | |
| 1690 | - | key <POWR> { [ XF86PowerOff ] }; | |
| 1691 | - | key <KPEQ> { [ KP_Equal ] }; | |
| 1692 | - | key <I126> { [ plusminus ] }; | |
| 1693 | - | key <PAUS> { | |
| 1694 | - | type= "PC_CONTROL_LEVEL2", | |
| 1695 | - | symbols[1]= [ Pause, Break ] | |
| 1696 | - | }; | |
| 1697 | - | key <I128> { [ XF86LaunchA ] }; | |
| 1698 | - | key <I129> { [ KP_Decimal, KP_Decimal ] }; | |
| 1699 | - | key <HNGL> { [ Hangul ] }; | |
| 1700 | - | key <HJCV> { [ Hangul_Hanja ] }; | |
| 1701 | - | key <LWIN> { [ Super_L ] }; | |
| 1702 | - | key <RWIN> { [ Super_R ] }; | |
| 1703 | - | key <COMP> { [ Menu ] }; | |
| 1704 | - | key <STOP> { [ Cancel ] }; | |
| 1705 | - | key <AGAI> { [ Redo ] }; | |
| 1706 | - | key <PROP> { [ SunProps ] }; | |
| 1707 | - | key <UNDO> { [ Undo ] }; | |
| 1708 | - | key <FRNT> { [ SunFront ] }; | |
| 1709 | - | key <COPY> { [ XF86Copy ] }; | |
| 1710 | - | key <OPEN> { [ XF86Open ] }; | |
| 1711 | - | key <PAST> { [ XF86Paste ] }; | |
| 1712 | - | key <FIND> { [ Find ] }; | |
| 1713 | - | key <CUT> { [ XF86Cut ] }; | |
| 1714 | - | key <HELP> { [ Help ] }; | |
| 1715 | - | key <I147> { [ XF86MenuKB ] }; | |
| 1716 | - | key <I148> { [ XF86Calculator ] }; | |
| 1717 | - | key <I150> { [ XF86Sleep ] }; | |
| 1718 | - | key <I151> { [ XF86WakeUp ] }; | |
| 1719 | - | key <I152> { [ XF86Explorer ] }; | |
| 1720 | - | key <I153> { [ XF86Send ] }; | |
| 1721 | - | key <I155> { [ XF86Xfer ] }; | |
| 1722 | - | key <I156> { [ XF86Launch1 ] }; | |
| 1723 | - | key <I157> { [ XF86Launch2 ] }; | |
| 1724 | - | key <I158> { [ XF86WWW ] }; | |
| 1725 | - | key <I159> { [ XF86DOS ] }; | |
| 1726 | - | key <I160> { [ XF86ScreenSaver ] }; | |
| 1727 | - | key <I161> { [ XF86RotateWindows ] }; | |
| 1728 | - | key <I162> { [ XF86TaskPane ] }; | |
| 1729 | - | key <I163> { [ XF86Mail ] }; | |
| 1730 | - | key <I164> { [ XF86Favorites ] }; | |
| 1731 | - | key <I165> { [ XF86MyComputer ] }; | |
| 1732 | - | key <I166> { [ XF86Back ] }; | |
| 1733 | - | key <I167> { [ XF86Forward ] }; | |
| 1734 | - | key <I169> { [ XF86Eject ] }; | |
| 1735 | - | key <I170> { [ XF86Eject ] }; | |
| 1736 | - | key <I171> { [ XF86AudioNext ] }; | |
| 1737 | - | key <I172> { [ XF86AudioPlay, XF86AudioPause ] }; | |
| 1738 | - | key <I173> { [ XF86AudioPrev ] }; | |
| 1739 | - | key <I174> { [ XF86AudioStop, XF86Eject ] }; | |
| 1740 | - | key <I175> { [ XF86AudioRecord ] }; | |
| 1741 | - | key <I176> { [ XF86AudioRewind ] }; | |
| 1742 | - | key <I177> { [ XF86Phone ] }; | |
| 1743 | - | key <I179> { [ XF86Tools ] }; | |
| 1744 | - | key <I180> { [ XF86HomePage ] }; | |
| 1745 | - | key <I181> { [ XF86Reload ] }; | |
| 1746 | - | key <I182> { [ XF86Close ] }; | |
| 1747 | - | key <I185> { [ XF86ScrollUp ] }; | |
| 1748 | - | key <I186> { [ XF86ScrollDown ] }; | |
| 1749 | - | key <I187> { [ parenleft ] }; | |
| 1750 | - | key <I188> { [ parenright ] }; | |
| 1751 | - | key <I189> { [ XF86New ] }; | |
| 1752 | - | key <I190> { [ Redo ] }; | |
| 1753 | - | key <FK13> { [ XF86Tools ] }; | |
| 1754 | - | key <FK14> { [ XF86Launch5 ] }; | |
| 1755 | - | key <FK15> { [ XF86Launch6 ] }; | |
| 1756 | - | key <FK16> { [ XF86Launch7 ] }; | |
| 1757 | - | key <FK17> { [ XF86Launch8 ] }; | |
| 1758 | - | key <FK18> { [ XF86Launch9 ] }; | |
| 1759 | - | key <FK19> { [ F19 ] }; | |
| 1760 | - | key <FK20> { [ XF86AudioMicMute ] }; | |
| 1761 | - | key <FK21> { [ XF86TouchpadToggle ] }; | |
| 1762 | - | key <FK22> { [ XF86TouchpadOn ] }; | |
| 1763 | - | key <FK23> { | |
| 1764 | - | type= "PC_SHIFT_SUPER_LEVEL2", | |
| 1765 | - | symbols[1]= [ F23, XF86Assistant ] | |
| 1766 | - | }; | |
| 1767 | - | key <FK24> { | |
| 1768 | - | type= "PC_CONTROL_SUPER_LEVEL2", | |
| 1769 | - | symbols[1]= [ F24, XF86TouchpadToggle ] | |
| 1770 | - | }; | |
| 1771 | - | key <LVL5> { [ ISO_Level5_Shift ] }; | |
| 1772 | - | key <ALT> { [ NoSymbol, Alt_L ] }; | |
| 1773 | - | key <META> { [ NoSymbol, Meta_L ] }; | |
| 1774 | - | key <SUPR> { [ NoSymbol, Super_L ] }; | |
| 1775 | - | key <HYPR> { [ NoSymbol, Hyper_L ] }; | |
| 1776 | - | key <I208> { [ XF86AudioPlay ] }; | |
| 1777 | - | key <I209> { [ XF86AudioPause ] }; | |
| 1778 | - | key <I210> { [ XF86Launch3 ] }; | |
| 1779 | - | key <I211> { [ XF86Launch4 ] }; | |
| 1780 | - | key <I212> { [ XF86LaunchB ] }; | |
| 1781 | - | key <I213> { [ XF86Suspend ] }; | |
| 1782 | - | key <I214> { [ XF86Close ] }; | |
| 1783 | - | key <I215> { [ XF86AudioPlay ] }; | |
| 1784 | - | key <I216> { [ XF86AudioForward ] }; | |
| 1785 | - | key <I217> { [ XF86AudioBassBoost ] }; | |
| 1786 | - | key <I218> { [ Print ] }; | |
| 1787 | - | key <I220> { [ XF86WebCam ] }; | |
| 1788 | - | key <I221> { [ XF86AudioPreset ] }; | |
| 1789 | - | key <I223> { [ XF86Mail ] }; | |
| 1790 | - | key <I224> { [ XF86Messenger ] }; | |
| 1791 | - | key <I225> { [ XF86Search ] }; | |
| 1792 | - | key <I226> { [ XF86Go ] }; | |
| 1793 | - | key <I227> { [ XF86Finance ] }; | |
| 1794 | - | key <I228> { [ XF86Game ] }; | |
| 1795 | - | key <I229> { [ XF86Shop ] }; | |
| 1796 | - | key <I231> { [ Cancel ] }; | |
| 1797 | - | key <I232> { [ XF86MonBrightnessDown ] }; | |
| 1798 | - | key <I233> { [ XF86MonBrightnessUp ] }; | |
| 1799 | - | key <I234> { [ XF86AudioMedia ] }; | |
| 1800 | - | key <I235> { [ XF86Display ] }; | |
| 1801 | - | key <I236> { [ XF86KbdLightOnOff ] }; | |
| 1802 | - | key <I237> { [ XF86KbdBrightnessDown ] }; | |
| 1803 | - | key <I238> { [ XF86KbdBrightnessUp ] }; | |
| 1804 | - | key <I239> { [ XF86Send ] }; | |
| 1805 | - | key <I240> { [ XF86Reply ] }; | |
| 1806 | - | key <I241> { [ XF86MailForward ] }; | |
| 1807 | - | key <I242> { [ XF86Save ] }; | |
| 1808 | - | key <I243> { [ XF86Documents ] }; | |
| 1809 | - | key <I244> { [ XF86Battery ] }; | |
| 1810 | - | key <I245> { [ XF86Bluetooth ] }; | |
| 1811 | - | key <I246> { [ XF86WLAN ] }; | |
| 1812 | - | key <I247> { [ XF86UWB ] }; | |
| 1813 | - | key <I249> { [ XF86Next_VMode ] }; | |
| 1814 | - | key <I250> { [ XF86Prev_VMode ] }; | |
| 1815 | - | key <I251> { [ XF86MonBrightnessCycle ] }; | |
| 1816 | - | key <I252> { [ XF86BrightnessAuto ] }; | |
| 1817 | - | key <I253> { [ XF86DisplayOff ] }; | |
| 1818 | - | key <I254> { [ XF86WWAN ] }; | |
| 1819 | - | key <I255> { [ XF86RFKill ] }; | |
| 1820 | - | key <I256> { [ XF86AudioMicMute ] }; | |
| 1821 | - | key <I360> { [ XF86OK ] }; | |
| 1822 | - | key <I361> { [ XF86Select ] }; | |
| 1823 | - | key <I362> { [ XF86GoTo ] }; | |
| 1824 | - | key <I363> { [ XF86Clear ] }; | |
| 1825 | - | key <I365> { [ XF86Option ] }; | |
| 1826 | - | key <I366> { [ XF86Info ] }; | |
| 1827 | - | key <I367> { [ XF86Time ] }; | |
| 1828 | - | key <I368> { [ XF86VendorLogo ] }; | |
| 1829 | - | key <I370> { [ XF86MediaSelectProgramGuide ] }; | |
| 1830 | - | key <I371> { [ XF86NextFavorite ] }; | |
| 1831 | - | key <I372> { [ XF86Favorites ] }; | |
| 1832 | - | key <I373> { [ XF86MediaSelectProgramGuide ] }; | |
| 1833 | - | key <I374> { [ XF86MediaSelectHome ] }; | |
| 1834 | - | key <I376> { [ XF86MediaLanguageMenu ] }; | |
| 1835 | - | key <I377> { [ XF86MediaTitleMenu ] }; | |
| 1836 | - | key <I378> { [ XF86Subtitle ] }; | |
| 1837 | - | key <I379> { [ XF86CycleAngle ] }; | |
| 1838 | - | key <I380> { [ XF86FullScreen ] }; | |
| 1839 | - | key <I381> { [ XF86AudioChannelMode ] }; | |
| 1840 | - | key <I382> { [ XF86Keyboard ] }; | |
| 1841 | - | key <I383> { [ XF86AspectRatio ] }; | |
| 1842 | - | key <I384> { [ XF86MediaSelectPC ] }; | |
| 1843 | - | key <I385> { [ XF86MediaSelectTV ] }; | |
| 1844 | - | key <I386> { [ XF86MediaSelectCable ] }; | |
| 1845 | - | key <I387> { [ XF86MediaSelectVCR ] }; | |
| 1846 | - | key <I388> { [ XF86MediaSelectVCRPlus ] }; | |
| 1847 | - | key <I389> { [ XF86MediaSelectSatellite ] }; | |
| 1848 | - | key <I391> { [ XF86CD ] }; | |
| 1849 | - | key <I392> { [ XF86MediaSelectTape ] }; | |
| 1850 | - | key <I393> { [ XF86MediaSelectRadio ] }; | |
| 1851 | - | key <I394> { [ XF86MediaSelectTuner ] }; | |
| 1852 | - | key <I395> { [ XF86MediaPlayer ] }; | |
| 1853 | - | key <I396> { [ XF86MediaSelectTeletext ] }; | |
| 1854 | - | key <I397> { [ XF86DVD ] }; | |
| 1855 | - | key <I398> { [ XF86MediaSelectAuxiliary ] }; | |
| 1856 | - | key <I400> { [ XF86Audio ] }; | |
| 1857 | - | key <I401> { [ XF86Video ] }; | |
| 1858 | - | key <I404> { [ XF86Memo ] }; | |
| 1859 | - | key <I405> { [ XF86Calendar ] }; | |
| 1860 | - | key <I406> { [ XF86Red ] }; | |
| 1861 | - | key <I407> { [ XF86Green ] }; | |
| 1862 | - | key <I408> { [ XF86Yellow ] }; | |
| 1863 | - | key <I409> { [ XF86Blue ] }; | |
| 1864 | - | key <I410> { [ XF86ChannelUp ] }; | |
| 1865 | - | key <I411> { [ XF86ChannelDown ] }; | |
| 1866 | - | key <I417> { [ XF86MediaPlaySlow ] }; | |
| 1867 | - | key <I418> { [ XF86AudioRandomPlay ] }; | |
| 1868 | - | key <I419> { [ XF86Break ] }; | |
| 1869 | - | key <I421> { [ XF86NumberEntryMode ] }; | |
| 1870 | - | key <I424> { [ XF86VideoPhone ] }; | |
| 1871 | - | key <I425> { [ XF86Game ] }; | |
| 1872 | - | key <I426> { [ XF86ZoomIn ] }; | |
| 1873 | - | key <I427> { [ XF86ZoomOut ] }; | |
| 1874 | - | key <I428> { [ XF86ZoomReset ] }; | |
| 1875 | - | key <I429> { [ XF86Word ] }; | |
| 1876 | - | key <I430> { [ XF86Editor ] }; | |
| 1877 | - | key <I431> { [ XF86Excel ] }; | |
| 1878 | - | key <I432> { [ XF86GraphicsEditor ] }; | |
| 1879 | - | key <I433> { [ XF86Presentation ] }; | |
| 1880 | - | key <I434> { [ XF86Database ] }; | |
| 1881 | - | key <I435> { [ XF86News ] }; | |
| 1882 | - | key <I436> { [ XF86Voicemail ] }; | |
| 1883 | - | key <I437> { [ XF86Addressbook ] }; | |
| 1884 | - | key <I438> { [ XF86Messenger ] }; | |
| 1885 | - | key <I439> { [ XF86DisplayToggle ] }; | |
| 1886 | - | key <I440> { [ XF86SpellCheck ] }; | |
| 1887 | - | key <I441> { [ XF86LogOff ] }; | |
| 1888 | - | key <I442> { [ dollar ] }; | |
| 1889 | - | key <I443> { [ EuroSign ] }; | |
| 1890 | - | key <I444> { [ XF86FrameBack ] }; | |
| 1891 | - | key <I445> { [ XF86FrameForward ] }; | |
| 1892 | - | key <I446> { [ XF86ContextMenu ] }; | |
| 1893 | - | key <I447> { [ XF86MediaRepeat ] }; | |
| 1894 | - | key <I448> { [ XF8610ChannelsUp ] }; | |
| 1895 | - | key <I449> { [ XF8610ChannelsDown ] }; | |
| 1896 | - | key <I450> { [ XF86Images ] }; | |
| 1897 | - | key <I452> { [ XF86NotificationCenter ] }; | |
| 1898 | - | key <I453> { [ XF86PickupPhone ] }; | |
| 1899 | - | key <I454> { [ XF86HangupPhone ] }; | |
| 1900 | - | key <I455> { [ XF86LinkPhone ] }; | |
| 1901 | - | key <I472> { [ XF86Fn ] }; | |
| 1902 | - | key <I473> { [ XF86Fn_Esc ] }; | |
| 1903 | - | key <I474> { [ XF86Fn_F1 ] }; | |
| 1904 | - | key <I475> { [ XF86Fn_F2 ] }; | |
| 1905 | - | key <I476> { [ XF86Fn_F3 ] }; | |
| 1906 | - | key <I477> { [ XF86Fn_F4 ] }; | |
| 1907 | - | key <I478> { [ XF86Fn_F5 ] }; | |
| 1908 | - | key <I479> { [ XF86Fn_F6 ] }; | |
| 1909 | - | key <I480> { [ XF86Fn_F7 ] }; | |
| 1910 | - | key <I481> { [ XF86Fn_F8 ] }; | |
| 1911 | - | key <I482> { [ XF86Fn_F9 ] }; | |
| 1912 | - | key <I483> { [ XF86Fn_F10 ] }; | |
| 1913 | - | key <I484> { [ XF86Fn_F11 ] }; | |
| 1914 | - | key <I485> { [ XF86Fn_F12 ] }; | |
| 1915 | - | key <I486> { [ XF86Fn_1 ] }; | |
| 1916 | - | key <I487> { [ XF86Fn_2 ] }; | |
| 1917 | - | key <I488> { [ XF86Fn_D ] }; | |
| 1918 | - | key <I489> { [ XF86Fn_E ] }; | |
| 1919 | - | key <I490> { [ XF86Fn_F ] }; | |
| 1920 | - | key <I491> { [ XF86Fn_S ] }; | |
| 1921 | - | key <I492> { [ XF86Fn_B ] }; | |
| 1922 | - | key <I493> { [ XF86FnRightShift ] }; | |
| 1923 | - | key <I505> { [ braille_dot_1 ] }; | |
| 1924 | - | key <I506> { [ braille_dot_2 ] }; | |
| 1925 | - | key <I507> { [ braille_dot_3 ] }; | |
| 1926 | - | key <I508> { [ braille_dot_4 ] }; | |
| 1927 | - | key <I509> { [ braille_dot_5 ] }; | |
| 1928 | - | key <I510> { [ braille_dot_6 ] }; | |
| 1929 | - | key <I511> { [ braille_dot_7 ] }; | |
| 1930 | - | key <I512> { [ braille_dot_8 ] }; | |
| 1931 | - | key <I513> { [ braille_dot_9 ] }; | |
| 1932 | - | key <I514> { [ braille_dot_1 ] }; | |
| 1933 | - | key <I520> { [ XF86Numeric0 ] }; | |
| 1934 | - | key <I521> { [ XF86Numeric1 ] }; | |
| 1935 | - | key <I522> { [ XF86Numeric2 ] }; | |
| 1936 | - | key <I523> { [ XF86Numeric3 ] }; | |
| 1937 | - | key <I524> { [ XF86Numeric4 ] }; | |
| 1938 | - | key <I525> { [ XF86Numeric5 ] }; | |
| 1939 | - | key <I526> { [ XF86Numeric6 ] }; | |
| 1940 | - | key <I527> { [ XF86Numeric7 ] }; | |
| 1941 | - | key <I528> { [ XF86Numeric8 ] }; | |
| 1942 | - | key <I529> { [ XF86Numeric9 ] }; | |
| 1943 | - | key <I530> { [ XF86NumericStar ] }; | |
| 1944 | - | key <I531> { [ XF86NumericPound ] }; | |
| 1945 | - | key <I532> { [ XF86NumericA ] }; | |
| 1946 | - | key <I533> { [ XF86NumericB ] }; | |
| 1947 | - | key <I534> { [ XF86NumericC ] }; | |
| 1948 | - | key <I535> { [ XF86NumericD ] }; | |
| 1949 | - | key <I536> { [ XF86CameraFocus ] }; | |
| 1950 | - | key <I537> { [ XF86WPSButton ] }; | |
| 1951 | - | key <I538> { [ XF86TouchpadToggle ] }; | |
| 1952 | - | key <I539> { [ XF86TouchpadOn ] }; | |
| 1953 | - | key <I540> { [ XF86TouchpadOff ] }; | |
| 1954 | - | key <I541> { [ XF86CameraZoomIn ] }; | |
| 1955 | - | key <I542> { [ XF86CameraZoomOut ] }; | |
| 1956 | - | key <I543> { [ XF86CameraUp ] }; | |
| 1957 | - | key <I544> { [ XF86CameraDown ] }; | |
| 1958 | - | key <I545> { [ XF86CameraLeft ] }; | |
| 1959 | - | key <I546> { [ XF86CameraRight ] }; | |
| 1960 | - | key <I547> { [ XF86AttendantOn ] }; | |
| 1961 | - | key <I548> { [ XF86AttendantOff ] }; | |
| 1962 | - | key <I549> { [ XF86AttendantToggle ] }; | |
| 1963 | - | key <I550> { [ XF86LightsToggle ] }; | |
| 1964 | - | key <I568> { [ XF86ALSToggle ] }; | |
| 1965 | - | key <I569> { [ XF86RotationLockToggle ] }; | |
| 1966 | - | key <I570> { [ XF86RefreshRateToggle ] }; | |
| 1967 | - | key <I584> { [ XF86Buttonconfig ] }; | |
| 1968 | - | key <I585> { [ XF86Taskmanager ] }; | |
| 1969 | - | key <I586> { [ XF86Journal ] }; | |
| 1970 | - | key <I587> { [ XF86ControlPanel ] }; | |
| 1971 | - | key <I588> { [ XF86AppSelect ] }; | |
| 1972 | - | key <I589> { [ XF86Screensaver ] }; | |
| 1973 | - | key <I590> { [ XF86VoiceCommand ] }; | |
| 1974 | - | key <I591> { [ XF86Assistant ] }; | |
| 1975 | - | key <I592> { [ ISO_Next_Group ] }; | |
| 1976 | - | key <I593> { [ XF86EmojiPicker ] }; | |
| 1977 | - | key <I594> { [ XF86Dictate ] }; | |
| 1978 | - | key <I595> { [ XF86CameraAccessEnable ] }; | |
| 1979 | - | key <I596> { [ XF86CameraAccessDisable ] }; | |
| 1980 | - | key <I597> { [ XF86CameraAccessToggle ] }; | |
| 1981 | - | key <I598> { [ XF86Accessibility ] }; | |
| 1982 | - | key <I599> { [ XF86DoNotDisturb ] }; | |
| 1983 | - | key <I600> { [ XF86BrightnessMin ] }; | |
| 1984 | - | key <I601> { [ XF86BrightnessMax ] }; | |
| 1985 | - | key <I602> { [ XF86ElectronicPrivacyScreenOn ] }; | |
| 1986 | - | key <I603> { [ XF86ElectronicPrivacyScreenOff ] }; | |
| 1987 | - | key <I604> { [ XF86ActionOnSelection ] }; | |
| 1988 | - | key <I605> { [ XF86ContextualInsert ] }; | |
| 1989 | - | key <I606> { [ XF86ContextualQuery ] }; | |
| 1990 | - | key <I616> { [ XF86KbdInputAssistPrev ] }; | |
| 1991 | - | key <I617> { [ XF86KbdInputAssistNext ] }; | |
| 1992 | - | key <I618> { [ XF86KbdInputAssistPrevgroup ] }; | |
| 1993 | - | key <I619> { [ XF86KbdInputAssistNextgroup ] }; | |
| 1994 | - | key <I620> { [ XF86KbdInputAssistAccept ] }; | |
| 1995 | - | key <I621> { [ XF86KbdInputAssistCancel ] }; | |
| 1996 | - | key <I622> { [ XF86RightUp ] }; | |
| 1997 | - | key <I623> { [ XF86RightDown ] }; | |
| 1998 | - | key <I624> { [ XF86LeftUp ] }; | |
| 1999 | - | key <I625> { [ XF86LeftDown ] }; | |
| 2000 | - | key <I626> { [ XF86RootMenu ] }; | |
| 2001 | - | key <I627> { [ XF86MediaTopMenu ] }; | |
| 2002 | - | key <I628> { [ XF86Numeric11 ] }; | |
| 2003 | - | key <I629> { [ XF86Numeric12 ] }; | |
| 2004 | - | key <I630> { [ XF86AudioDesc ] }; | |
| 2005 | - | key <I631> { [ XF863DMode ] }; | |
| 2006 | - | key <I632> { [ XF86NextFavorite ] }; | |
| 2007 | - | key <I633> { [ XF86StopRecord ] }; | |
| 2008 | - | key <I634> { [ XF86PauseRecord ] }; | |
| 2009 | - | key <I635> { [ XF86VOD ] }; | |
| 2010 | - | key <I636> { [ XF86Unmute ] }; | |
| 2011 | - | key <I637> { [ XF86FastReverse ] }; | |
| 2012 | - | key <I638> { [ XF86SlowReverse ] }; | |
| 2013 | - | key <I639> { [ XF86Data ] }; | |
| 2014 | - | key <I640> { [ XF86OnScreenKeyboard ] }; | |
| 2015 | - | key <I641> { [ XF86PrivacyScreenToggle ] }; | |
| 2016 | - | key <I642> { [ XF86SelectiveScreenshot ] }; | |
| 2017 | - | key <I643> { [ XF86NextElement ] }; | |
| 2018 | - | key <I644> { [ XF86PreviousElement ] }; | |
| 2019 | - | key <I645> { [ XF86AutopilotEngageToggle ] }; | |
| 2020 | - | key <I646> { [ XF86MarkWaypoint ] }; | |
| 2021 | - | key <I647> { [ XF86Sos ] }; | |
| 2022 | - | key <I648> { [ XF86NavChart ] }; | |
| 2023 | - | key <I649> { [ XF86FishingChart ] }; | |
| 2024 | - | key <I650> { [ XF86SingleRangeRadar ] }; | |
| 2025 | - | key <I651> { [ XF86DualRangeRadar ] }; | |
| 2026 | - | key <I652> { [ XF86RadarOverlay ] }; | |
| 2027 | - | key <I653> { [ XF86TraditionalSonar ] }; | |
| 2028 | - | key <I654> { [ XF86ClearvuSonar ] }; | |
| 2029 | - | key <I655> { [ XF86SidevuSonar ] }; | |
| 2030 | - | key <I656> { [ XF86NavInfo ] }; | |
| 2031 | - | key <I657> { [ XF86BrightnessAdjust ] }; | |
| 2032 | - | key <I664> { [ XF86Macro1 ] }; | |
| 2033 | - | key <I665> { [ XF86Macro2 ] }; | |
| 2034 | - | key <I666> { [ XF86Macro3 ] }; | |
| 2035 | - | key <I667> { [ XF86Macro4 ] }; | |
| 2036 | - | key <I668> { [ XF86Macro5 ] }; | |
| 2037 | - | key <I669> { [ XF86Macro6 ] }; | |
| 2038 | - | key <I670> { [ XF86Macro7 ] }; | |
| 2039 | - | key <I671> { [ XF86Macro8 ] }; | |
| 2040 | - | key <I672> { [ XF86Macro9 ] }; | |
| 2041 | - | key <I673> { [ XF86Macro10 ] }; | |
| 2042 | - | key <I674> { [ XF86Macro11 ] }; | |
| 2043 | - | key <I675> { [ XF86Macro12 ] }; | |
| 2044 | - | key <I676> { [ XF86Macro13 ] }; | |
| 2045 | - | key <I677> { [ XF86Macro14 ] }; | |
| 2046 | - | key <I678> { [ XF86Macro15 ] }; | |
| 2047 | - | key <I679> { [ XF86Macro16 ] }; | |
| 2048 | - | key <I680> { [ XF86Macro17 ] }; | |
| 2049 | - | key <I681> { [ XF86Macro18 ] }; | |
| 2050 | - | key <I682> { [ XF86Macro19 ] }; | |
| 2051 | - | key <I683> { [ XF86Macro20 ] }; | |
| 2052 | - | key <I684> { [ XF86Macro21 ] }; | |
| 2053 | - | key <I685> { [ XF86Macro22 ] }; | |
| 2054 | - | key <I686> { [ XF86Macro23 ] }; | |
| 2055 | - | key <I687> { [ XF86Macro24 ] }; | |
| 2056 | - | key <I688> { [ XF86Macro25 ] }; | |
| 2057 | - | key <I689> { [ XF86Macro26 ] }; | |
| 2058 | - | key <I690> { [ XF86Macro27 ] }; | |
| 2059 | - | key <I691> { [ XF86Macro28 ] }; | |
| 2060 | - | key <I692> { [ XF86Macro29 ] }; | |
| 2061 | - | key <I693> { [ XF86Macro30 ] }; | |
| 2062 | - | key <I696> { [ XF86MacroRecordStart ] }; | |
| 2063 | - | key <I697> { [ XF86MacroRecordStop ] }; | |
| 2064 | - | key <I698> { [ XF86MacroPresetCycle ] }; | |
| 2065 | - | key <I699> { [ XF86MacroPreset1 ] }; | |
| 2066 | - | key <I700> { [ XF86MacroPreset2 ] }; | |
| 2067 | - | key <I701> { [ XF86MacroPreset3 ] }; | |
| 2068 | - | key <I704> { [ XF86KbdLcdMenu1 ] }; | |
| 2069 | - | key <I705> { [ XF86KbdLcdMenu2 ] }; | |
| 2070 | - | key <I706> { [ XF86KbdLcdMenu3 ] }; | |
| 2071 | - | key <I707> { [ XF86KbdLcdMenu4 ] }; | |
| 2072 | - | key <I708> { [ XF86KbdLcdMenu5 ] }; | |
| 2073 | - | key <I709> { [ XF86PerformanceMode ] }; | |
| 2074 | - | modifier_map Shift { <LFSH>, <RTSH> }; | |
| 2075 | - | modifier_map Lock { <CAPS> }; | |
| 2076 | - | modifier_map Control { <LCTL>, <RCTL> }; | |
| 2077 | - | modifier_map Mod1 { <LALT>, <ALT>, <META> }; | |
| 2078 | - | modifier_map Mod2 { <NMLK> }; | |
| 2079 | - | modifier_map Mod3 { <LVL5> }; | |
| 2080 | - | modifier_map Mod4 { <LWIN>, <RWIN>, <SUPR> }; | |
| 2081 | - | modifier_map Mod5 { <LVL3> }; | |
| 2082 | - | }; | |
| 2083 | - | ||
| 2084 | - | }; | |
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. | |
| 3 | 3 | package buffers | |
| 4 | 4 | ||
| 5 | 5 | 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 @@ | |||
| 3 | 3 | package session | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 9 | + | "github.com/bnema/nefergui/internal/presentation/buffers" | |
| 8 | 10 | "github.com/bnema/nefergui/internal/presentation/vkdevice" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -24,3 +26,31 @@ func TestInstallReleaseWaitDefersUnreadySubmission(t *testing.T) { | |||
| 24 | 26 | t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot) | |
| 25 | 27 | } | |
| 26 | 28 | } | |
| 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) { | |||
| 12 | 12 | for _, tc := range []struct { | |
| 13 | 13 | name string | |
| 14 | 14 | data []byte | |
| 15 | - | }{{"vertex", Vertex}, {"fragment", Fragment}, {"list vertex", ListVertex}, {"list fragment", ListFragment}} { | |
| 15 | + | }{{"list vertex", ListVertex}, {"list fragment", ListFragment}} { | |
| 16 | 16 | if len(tc.data) < 20 || !bytes.Equal(tc.data[:4], []byte{3, 2, 35, 7}) || len(tc.data)%4 != 0 { | |
| 17 | 17 | t.Fatalf("%s: not SPIR-V", tc.name) | |
| 18 | 18 | } | |
| @@ -27,7 +27,7 @@ func TestShaderFreshness(t *testing.T) { | |||
| 27 | 27 | for _, tc := range []struct { | |
| 28 | 28 | file, stage string | |
| 29 | 29 | 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}} { | |
| 31 | 31 | out := filepath.Join(t.TempDir(), "shader.spv") | |
| 32 | 32 | cmd := exec.Command(bin, "-O", "-fshader-stage="+tc.stage, "-o", out, tc.file) | |
| 33 | 33 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import _ "embed" | |
| 6 | 6 | ||
| 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 | |
| 9 | 7 | //go:generate glslc -O -fshader-stage=vertex -o list.vert.spv list.vert | |
| 10 | 8 | //go:generate glslc -O -fshader-stage=fragment -o list.frag.spv list.frag | |
| 11 | 9 | ||
| 12 | - | //go:embed rect.vert.spv | |
| 13 | - | var Vertex []byte | |
| 14 | - | ||
| 15 | - | //go:embed rect.frag.spv | |
| 16 | - | var Fragment []byte | |
| 17 | - | ||
| 18 | 10 | //go:embed list.vert.spv | |
| 19 | 11 | var ListVertex []byte | |
| 20 | 12 | ||
M internal/presentation/syncobj/syncobj.go +38 -4
| @@ -41,6 +41,15 @@ type queryArg struct { | |||
| 41 | 41 | Count, Flags uint32 | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 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 | + | ||
| 44 | 53 | const ( | |
| 45 | 54 | ioctlCreate = uintptr(0xc00864bf) | |
| 46 | 55 | ioctlDestroy = uintptr(0xc00864c0) | |
| @@ -49,6 +58,8 @@ const ( | |||
| 49 | 58 | ioctlTimelineWait = uintptr(0xc03064ca) | |
| 50 | 59 | ioctlQuery = uintptr(0xc01864cb) | |
| 51 | 60 | ioctlTransfer = uintptr(0xc02064cc) | |
| 61 | + | ioctlTimelineSig = uintptr(0xc01864cd) | |
| 62 | + | ioctlEventFD = uintptr(0xc01864cf) | |
| 52 | 63 | importSyncFile = 1 | |
| 53 | 64 | exportSyncFile = 1 | |
| 54 | 65 | ) | |
| @@ -107,9 +118,8 @@ func (n *Node) Destroy(handle uint32) error { | |||
| 107 | 118 | return n.ioctl(ioctlDestroy, unsafe.Pointer(&destroyArg{Handle: handle})) | |
| 108 | 119 | } | |
| 109 | 120 | ||
| 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. | |
| 113 | 123 | func (n *Node) ExportTimeline(handle uint32) (int, error) { | |
| 114 | 124 | a := handleArg{Handle: handle, FD: -1} | |
| 115 | 125 | if err := n.ioctl(ioctlHandleToFD, unsafe.Pointer(&a)); err != nil { | |
| @@ -118,6 +128,30 @@ func (n *Node) ExportTimeline(handle uint32) (int, error) { | |||
| 118 | 128 | return int(a.FD), nil | |
| 119 | 129 | } | |
| 120 | 130 | ||
| 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 | + | ||
| 121 | 155 | // ImportFence consumes syncFile only after FD_TO_HANDLE imports it into a | |
| 122 | 156 | // pre-created destination syncobj. With IMPORT_SYNC_FILE, Handle is an input, | |
| 123 | 157 | // 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) { | |||
| 175 | 209 | } | |
| 176 | 210 | ||
| 177 | 211 | // 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 | |
| 179 | 213 | // handles the interval before the compositor attaches its fence to the point. | |
| 180 | 214 | func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bool, error) { | |
| 181 | 215 | if timeout < 0 || timeout > time.Second { | |
M internal/presentation/syncobj/syncobj_test.go +78 -0
| @@ -22,6 +22,7 @@ func TestUAPILayouts(t *testing.T) { | |||
| 22 | 22 | {"transfer", unsafe.Sizeof(transferArg{}), 32}, | |
| 23 | 23 | {"wait", unsafe.Sizeof(timelineWaitArg{}), 48}, | |
| 24 | 24 | {"query", unsafe.Sizeof(queryArg{}), 24}, | |
| 25 | + | {"eventfd", unsafe.Sizeof(eventFDArg{}), 24}, | |
| 25 | 26 | } { | |
| 26 | 27 | if tc.got != tc.want { | |
| 27 | 28 | t.Errorf("%s size: got %d want %d", tc.name, tc.got, tc.want) | |
| @@ -102,3 +103,80 @@ func TestFailedImportRetainsFD(t *testing.T) { | |||
| 102 | 103 | t.Fatalf("lost caller FD after failed import: %v", err) | |
| 103 | 104 | } | |
| 104 | 105 | } | |
| 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 ( | |||
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/purego-vulkan/vulkan" | |
| 11 | - | "github.com/bnema/wlturbo/protocol/core" | |
| 12 | - | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 13 | 11 | "golang.org/x/sys/unix" | |
| 14 | 12 | ) | |
| 15 | 13 | ||
| 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. | |
| 19 | 19 | type Image struct { | |
| 20 | 20 | Image vulkan.Image | |
| 21 | 21 | Memory vulkan.DeviceMemory | |
| 22 | - | Buffer *core.Buffer | |
| 22 | + | FD int // exported DMA-BUF, -1 when none | |
| 23 | 23 | Modifier uint64 | |
| 24 | 24 | Offset, Stride uint32 | |
| 25 | 25 | device *Device | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 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") | |
| 31 | 31 | } | |
| 32 | - | img = &Image{device: d} | |
| 32 | + | img = &Image{device: d, FD: -1} | |
| 33 | 33 | defer func() { | |
| 34 | 34 | if err != nil { | |
| 35 | 35 | img.Close() | |
| @@ -88,27 +88,7 @@ func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *l | |||
| 88 | 88 | if err = vulkan.Check(d.Dispatch.GetMemoryFdKHR(d.Logical, &fdInfo, &fd)); err != nil { | |
| 89 | 89 | return nil, fmt.Errorf("export DMA-BUF: %w", err) | |
| 90 | 90 | } | |
| 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) | |
| 112 | 92 | return img, nil | |
| 113 | 93 | } | |
| 114 | 94 | ||
| @@ -116,9 +96,9 @@ func (img *Image) Close() { | |||
| 116 | 96 | if img == nil || img.device == nil { | |
| 117 | 97 | return | |
| 118 | 98 | } | |
| 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 | |
| 122 | 102 | } | |
| 123 | 103 | if img.Image != 0 { | |
| 124 | 104 | img.device.Dispatch.DestroyImage(img.device.Logical, img.Image, nil) | |
M internal/presentation/vkdevice/modifier.go +12 -7
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "unsafe" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/purego-vulkan/vulkan" | |
| 10 | - | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | 12 | // Modifier is the one-plane GPU/compositor common image layout chosen in | |
| @@ -22,16 +21,22 @@ const ( | |||
| 22 | 21 | ARGB8888 uint32 = 0x34325241 | |
| 23 | 22 | ) | |
| 24 | 23 | ||
| 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) { | |
| 26 | 33 | format := XRGB8888 | |
| 27 | 34 | if transparent { | |
| 28 | 35 | format = ARGB8888 | |
| 29 | 36 | } | |
| 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 | |
| 35 | 40 | } | |
| 36 | 41 | } | |
| 37 | 42 | 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 @@ | |||
| 3 | 3 | package vkdevice | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 7 | 6 | "golang.org/x/sys/unix" | |
| 8 | 7 | "os" | |
| 9 | 8 | "testing" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | 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}} | |
| 14 | 13 | for _, tc := range []struct { | |
| 15 | 14 | alpha bool | |
| 16 | 15 | available map[uint64]bool | |
M internal/presentation/vkdevice/pipeline.go +31 -61
| @@ -12,13 +12,6 @@ import ( | |||
| 12 | 12 | "github.com/bnema/purego-vulkan/vulkan" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 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 | - | ||
| 22 | 15 | // Instance is a physical-pixel list quad. Color is straight sRGB; the shader | |
| 23 | 16 | // premultiplies it. Clip uses x/y/width/height, radii are TL/TR/BR/BL. | |
| 24 | 17 | // KindLayer.x = 6 samples set 1 binding 0 as a 2D image texture. | |
| @@ -59,19 +52,11 @@ type Pipeline struct { | |||
| 59 | 52 | Layout vulkan.PipelineLayout | |
| 60 | 53 | } | |
| 61 | 54 | ||
| 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. | |
| 70 | 59 | 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) { | |
| 75 | 60 | if width <= 0 || height <= 0 { | |
| 76 | 61 | return nil, fmt.Errorf("invalid render area %dx%d", width, height) | |
| 77 | 62 | } | |
| @@ -81,44 +66,32 @@ func (d *Device) newPipeline(width, height int32, list bool) (p *Pipeline, err e | |||
| 81 | 66 | p.Close() | |
| 82 | 67 | } | |
| 83 | 68 | }() | |
| 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 | |
| 98 | 73 | } | |
| 99 | 74 | } | |
| 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 | + | } | |
| 108 | 80 | } | |
| 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} | |
| 109 | 86 | 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) | |
| 111 | 88 | } | |
| 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) | |
| 117 | 90 | if err != nil { | |
| 118 | 91 | return nil, err | |
| 119 | 92 | } | |
| 120 | 93 | defer d.Dispatch.DestroyShaderModule(d.Logical, vertex, nil) | |
| 121 | - | fragment, err := d.shaderModule(frag) | |
| 94 | + | fragment, err := d.shaderModule(shaders.ListFragment) | |
| 122 | 95 | if err != nil { | |
| 123 | 96 | return nil, err | |
| 124 | 97 | } | |
| @@ -129,19 +102,16 @@ func (d *Device) newPipeline(width, height int32, list bool) (p *Pipeline, err e | |||
| 129 | 102 | {SType: vulkan.StructureTypePipelineShaderStageCreateInfo, Stage: vulkan.ShaderStageFragmentBit, Module: fragment, Name: &main[0]}, | |
| 130 | 103 | } | |
| 131 | 104 | vertexInput := vulkan.PipelineVertexInputStateCreateInfo{SType: vulkan.StructureTypePipelineVertexInputStateCreateInfo} | |
| 132 | - | var binding vulkan.VertexInputBindingDescription | |
| 133 | 105 | 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] | |
| 145 | 115 | assembly := vulkan.PipelineInputAssemblyStateCreateInfo{SType: vulkan.StructureTypePipelineInputAssemblyStateCreateInfo, Topology: vulkan.PrimitiveTopologyTriangleList} | |
| 146 | 116 | viewport := vulkan.Viewport{Width: float32(width), Height: float32(height), MaxDepth: 1} | |
| 147 | 117 | 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 | |||
| 154 | 124 | rendering := vulkan.PipelineRenderingCreateInfo{SType: vulkan.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format} | |
| 155 | 125 | 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} | |
| 156 | 126 | 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) | |
| 158 | 128 | } | |
| 159 | 129 | return p, nil | |
| 160 | 130 | } | |
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 ( | |||
| 11 | 11 | "github.com/bnema/purego-vulkan/vulkan" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 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 | |
| 15 | 15 | // recorded after drawing and before the acquire semaphore signals; mapping | |
| 16 | 16 | // waits for the submission fence, never for compositor release. | |
| 17 | 17 | type Readback struct { | |
M internal/presentation/vkdevice/render.go +30 -43
| @@ -76,17 +76,13 @@ func (f *Frame) Ready() (bool, error) { | |||
| 76 | 76 | return true, nil | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 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 | - | ||
| 83 | 79 | // RecordList records an ordered list of physical-pixel quads. Reuse is gated | |
| 84 | 80 | // by Ready, including any instance-buffer growth. | |
| 85 | 81 | 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) | |
| 87 | 83 | } | |
| 88 | 84 | ||
| 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) { | |
| 90 | 86 | if width <= 0 || height <= 0 || pipe == nil { | |
| 91 | 87 | return fmt.Errorf("invalid render dimensions or pipeline") | |
| 92 | 88 | } | |
| @@ -109,15 +105,13 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 109 | 105 | } | |
| 110 | 106 | f.submitted = false | |
| 111 | 107 | } | |
| 112 | - | if rect == nil && f.device.atlas == nil { | |
| 108 | + | if f.device.atlas == nil { | |
| 113 | 109 | return fmt.Errorf("list atlas not initialized") | |
| 114 | 110 | } | |
| 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 | |
| 119 | 113 | } | |
| 120 | - | if rect == nil && f.device.images != nil { | |
| 114 | + | if f.device.images != nil { | |
| 121 | 115 | defer func() { | |
| 122 | 116 | if err != nil { | |
| 123 | 117 | f.device.images.abort(f) | |
| @@ -128,7 +122,7 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 128 | 122 | return err | |
| 129 | 123 | } | |
| 130 | 124 | } | |
| 131 | - | if rect == nil && len(instances) > 0 { | |
| 125 | + | if len(instances) > 0 { | |
| 132 | 126 | if err := f.uploadInstances(instances); err != nil { | |
| 133 | 127 | return err | |
| 134 | 128 | } | |
| @@ -138,12 +132,10 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 138 | 132 | if err = vulkan.Check(d.BeginCommandBuffer(f.Commands, &begin)); err != nil { | |
| 139 | 133 | return err | |
| 140 | 134 | } | |
| 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 | |
| 147 | 139 | f.device.atlas.recordUploads(f.Commands, staging, uploads) | |
| 148 | 140 | if !f.device.images.fallbackReady { | |
| 149 | 141 | f.recordFallback() | |
| @@ -164,32 +156,27 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 164 | 156 | rendering := vulkan.RenderingInfo{SType: vulkan.StructureTypeRenderingInfo, RenderArea: area, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment} | |
| 165 | 157 | d.CmdBeginRendering(f.Commands, &rendering) | |
| 166 | 158 | 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") | |
| 190 | 176 | } | |
| 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) | |
| 192 | 178 | } | |
| 179 | + | d.CmdDraw(f.Commands, 6, batch.Count, 0, batch.First) | |
| 193 | 180 | } | |
| 194 | 181 | } | |
| 195 | 182 | d.CmdEndRendering(f.Commands) | |
M internal/presentation/vkdevice/validation_test.go +1 -1
| @@ -29,7 +29,7 @@ func TestValidationLayer(t *testing.T) { | |||
| 29 | 29 | } | |
| 30 | 30 | t.Fatal(err) | |
| 31 | 31 | } | |
| 32 | - | pipeline, err := d.NewRectPipeline(64, 64) | |
| 32 | + | pipeline, err := d.NewListPipeline(64, 64) | |
| 33 | 33 | if err != nil { | |
| 34 | 34 | d.Close() | |
| 35 | 35 | t.Fatal(err) | |
M internal/render/instances.go +1 -1
| @@ -30,7 +30,7 @@ func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) | |||
| 30 | 30 | // The slices it returns alias its storage: they are valid only until the next | |
| 31 | 31 | // List or Release call, and must not be retained, shared across goroutines, or | |
| 32 | 32 | // 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 | |
| 34 | 34 | // resolve copies the batches before recording returns); anything that keeps the | |
| 35 | 35 | // slices past that point must use ListBatches instead. Retained frames hold | |
| 36 | 36 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "github.com/bnema/nefergui/internal/edit" | |
| 6 | + | "strconv" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -231,3 +232,35 @@ func (n Node) Heading(text string, options ...HeadingOption) { | |||
| 231 | 232 | } | |
| 232 | 233 | } | |
| 233 | 234 | } | |
| 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| 5 | 5 | "github.com/bnema/nefergui/internal/edit" | |
| 6 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 7 | 6 | "testing" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 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 } | |
| 14 | 9 | func TestRadioSliderAndEditingIdentity(t *testing.T) { | |
| 15 | 10 | r := newRuntime() | |
| 16 | 11 | r.styles = css.Compile(css.UA(), css.Parse(`input,radio,slider {height:20px}`)) | |
| @@ -85,7 +80,7 @@ func TestDisabledEditorRejectsFocusAndText(t *testing.T) { | |||
| 85 | 80 | if r.state.focus != nil { | |
| 86 | 81 | t.Fatalf("disabled input focused: %s", idKey(r.state.focus)) | |
| 87 | 82 | } | |
| 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}}) | |
| 89 | 84 | r.Build(view) | |
| 90 | 85 | if value != "keep" { | |
| 91 | 86 | t.Fatalf("disabled input edited: %q", value) | |
| @@ -98,10 +93,10 @@ func TestEditFocusIMEAndClipboard(t *testing.T) { | |||
| 98 | 93 | var value string | |
| 99 | 94 | view := func(f *Frame) { f.Root().Input("secret", &value, Key("secret"), Password(true)) } | |
| 100 | 95 | r.Build(view) | |
| 101 | - | r.clipboard = &clip{data: []byte("paste")} | |
| 96 | + | r.clipboard = NewMockClipboard(t) // no clipboard access expected | |
| 102 | 97 | r.route(key("Tab")) | |
| 103 | 98 | 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}}) | |
| 105 | 100 | r.Build(view) | |
| 106 | 101 | if value != "x" || r.committed.children[0].text != "•" { | |
| 107 | 102 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "reflect" | |
| 5 | 5 | "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) {} | |
| 30 | 6 | ||
| 31 | - | type recordingIME struct { | |
| 32 | - | surrounding []string | |
| 33 | - | password bool | |
| 34 | - | } | |
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | ) | |
| 35 | 9 | ||
| 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 | |
| 40 | 35 | } | |
| 41 | - | func (i *recordingIME) CursorRect(float64, float64, float64, float64) {} | |
| 42 | - | func (i *recordingIME) ContentType(password, _ bool) { i.password = password } | |
| 43 | 36 | ||
| 44 | 37 | func TestPasswordEditorNeverSendsSecretToIME(t *testing.T) { | |
| 45 | 38 | 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() | |
| 47 | 49 | r.ime = ime | |
| 48 | 50 | value := "hunter2" | |
| 49 | 51 | view := func(f *Frame) { f.Root().Input("secret", &value, Key("secret"), Password(true)) } | |
| 50 | 52 | r.Build(view) | |
| 51 | 53 | r.route(key("Tab")) | |
| 52 | 54 | r.Build(view) | |
| 53 | - | if !ime.password { | |
| 55 | + | if !password { | |
| 54 | 56 | t.Fatal("password content type not sent") | |
| 55 | 57 | } | |
| 56 | - | if len(ime.surrounding) == 0 { | |
| 58 | + | if len(surrounding) == 0 { | |
| 57 | 59 | t.Fatal("no surrounding text update") | |
| 58 | 60 | } | |
| 59 | - | for _, s := range ime.surrounding { | |
| 61 | + | for _, s := range surrounding { | |
| 60 | 62 | if s != "" { | |
| 61 | 63 | t.Fatalf("surrounding text leaked %q", s) | |
| 62 | 64 | } | |
| @@ -65,7 +67,7 @@ func TestPasswordEditorNeverSendsSecretToIME(t *testing.T) { | |||
| 65 | 67 | ||
| 66 | 68 | func TestIMEFocusTransferDisablesBeforeEnable(t *testing.T) { | |
| 67 | 69 | r := newRuntime() | |
| 68 | - | ime := &orderedIME{} | |
| 70 | + | ime, calls := newOrderedIME(t) | |
| 69 | 71 | r.ime = ime | |
| 70 | 72 | a, b := "one", "two" | |
| 71 | 73 | 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) { | |||
| 76 | 78 | r.Build(view) | |
| 77 | 79 | r.route(key("Tab")) | |
| 78 | 80 | 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) | |
| 81 | 83 | } | |
| 82 | 84 | if !r.edits[idKey(r.Target(0))].IMEActive() || r.edits[idKey(r.Target(1))].IMEActive() { | |
| 83 | 85 | t.Fatal("wrong active editor") | |
M internal/ui/ime_return_test.go +3 -4
| @@ -4,18 +4,17 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/nefergui/internal/edit" | |
| 7 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 10 | 9 | 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}} | |
| 12 | 11 | } | |
| 13 | 12 | ||
| 14 | 13 | // With text-input v3 the compositor routes keys to a grabbing input method; | |
| 15 | 14 | // keys that still arrive on wl_keyboard were not consumed and must be typed. | |
| 16 | 15 | func TestKeyboardTextInsertedWhileIMEEnabled(t *testing.T) { | |
| 17 | 16 | r := newRuntime() | |
| 18 | - | ime := &orderedIME{} | |
| 17 | + | ime, calls := newOrderedIME(t) | |
| 19 | 18 | r.ime = ime | |
| 20 | 19 | value := "ab" | |
| 21 | 20 | view := func(f *Frame) { f.Root().Textarea("message", &value) } | |
| @@ -29,7 +28,7 @@ func TestKeyboardTextInsertedWhileIMEEnabled(t *testing.T) { | |||
| 29 | 28 | r.route(k) | |
| 30 | 29 | r.Build(view) | |
| 31 | 30 | } | |
| 32 | - | if value != "abca\n\n" || len(ime.calls) == 0 { | |
| 31 | + | if value != "abca\n\n" || len(*calls) == 0 { | |
| 33 | 32 | t.Fatalf("keyboard text lost while IME enabled: %q", value) | |
| 34 | 33 | } | |
| 35 | 34 | ||
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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/nefergui/internal/css" | |
| 10 | 10 | "github.com/bnema/nefergui/internal/edit" | |
| 11 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 12 | 11 | "github.com/bnema/nefergui/internal/layout" | |
| 13 | 12 | "github.com/bnema/nefergui/internal/text" | |
| 14 | 13 | ) | |
| @@ -19,13 +18,20 @@ type platformInput struct { | |||
| 19 | 18 | Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize | |
| 20 | 19 | X, Y, DX, DY float64 | |
| 21 | 20 | Width, Height, Scale float64 | |
| 22 | - | Key keyboard.Key | |
| 21 | + | Key keyEvent | |
| 23 | 22 | Button uint32 // evdev button code (BTN_LEFT is primary) | |
| 24 | 23 | Shift, Ctrl bool | |
| 25 | 24 | IME edit.IMEBatch | |
| 26 | 25 | Time time.Time // optional event timestamp; zero uses receipt time | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 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 | + | ||
| 29 | 35 | const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT | |
| 30 | 36 | ||
| 31 | 37 | type interactionState struct { | |
M internal/ui/interaction_test.go +10 -12
| @@ -6,13 +6,11 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/nefergui/internal/css" | |
| 9 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 10 | - | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 11 | 9 | "github.com/bnema/nefergui/internal/text" | |
| 12 | 10 | ) | |
| 13 | 11 | ||
| 14 | 12 | 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}} | |
| 16 | 14 | } | |
| 17 | 15 | func shell(r *runtime, activated *int) func(*Frame) { | |
| 18 | 16 | return func(f *Frame) { | |
| @@ -67,7 +65,7 @@ func TestShellStyleAndFocus(t *testing.T) { | |||
| 67 | 65 | if !r.state.focus.same(r.Target(3)) { | |
| 68 | 66 | t.Fatal("disabled included in tab order") | |
| 69 | 67 | } | |
| 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}}) | |
| 71 | 69 | r.Build(view) | |
| 72 | 70 | if !r.state.focus.same(r.Target(1)) { | |
| 73 | 71 | t.Fatal("reverse tab") | |
| @@ -257,13 +255,13 @@ func TestCursorFollowsHoverAndCapture(t *testing.T) { | |||
| 257 | 255 | } | |
| 258 | 256 | for _, c := range []struct { | |
| 259 | 257 | 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 { | |
| 263 | 261 | t.Errorf("y=%v shape %v, want %v", c.y, got, c.want) | |
| 264 | 262 | } | |
| 265 | 263 | } | |
| 266 | - | if cursorShape(css.KeywordAuto) != cursorShape(css.KeywordDefault) { | |
| 264 | + | if cursorOf(css.KeywordAuto) != cursorOf(css.KeywordDefault) { | |
| 267 | 265 | t.Error("auto must map to the default shape") | |
| 268 | 266 | } | |
| 269 | 267 | // Press on the input, drag over the button: the input keeps its cursor. | |
| @@ -361,7 +359,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 361 | 359 | if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 { | |
| 362 | 360 | t.Fatal("space press should only set active") | |
| 363 | 361 | } | |
| 364 | - | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 362 | + | r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}}) | |
| 365 | 363 | r.Build(view) | |
| 366 | 364 | if count != 2 || r.state.space != nil { | |
| 367 | 365 | t.Fatal("space release should activate once", count) | |
| @@ -369,7 +367,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 369 | 367 | r.route(space) | |
| 370 | 368 | r.route(key("Escape")) | |
| 371 | 369 | r.Build(view) | |
| 372 | - | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 370 | + | r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}}) | |
| 373 | 371 | if r.Build(view) { | |
| 374 | 372 | t.Fatal("cancelled release redrew") | |
| 375 | 373 | } | |
| @@ -379,7 +377,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 379 | 377 | r.route(space) | |
| 380 | 378 | r.route(platformInput{Kind: "focus-out"}) | |
| 381 | 379 | r.Build(view) | |
| 382 | - | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 380 | + | r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}}) | |
| 383 | 381 | if r.Build(view) || count != 2 { | |
| 384 | 382 | t.Fatal("focus loss failed to cancel") | |
| 385 | 383 | } | |
| @@ -388,7 +386,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 388 | 386 | r.route(space) | |
| 389 | 387 | r.route(key("Tab")) | |
| 390 | 388 | r.Build(view) | |
| 391 | - | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 389 | + | r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}}) | |
| 392 | 390 | if r.Build(view) || count != 2 { | |
| 393 | 391 | t.Fatal("focus change failed to cancel") | |
| 394 | 392 | } | |
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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "github.com/bnema/nefergui/internal/edit" | |
| 7 | 6 | "testing" | |
| 8 | - | ) | |
| 9 | 7 | ||
| 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 | + | ) | |
| 14 | 11 | ||
| 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 | - | } | |
| 25 | 12 | func TestAsyncPasteFrameAndCancellation(t *testing.T) { | |
| 26 | 13 | 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} | |
| 29 | 24 | name := "" | |
| 30 | 25 | var changed bool | |
| 31 | 26 | var diagnostics []string | |
| @@ -45,28 +40,28 @@ func TestAsyncPasteFrameAndCancellation(t *testing.T) { | |||
| 45 | 40 | } | |
| 46 | 41 | paste() | |
| 47 | 42 | paste() | |
| 48 | - | if c.contexts[0].Err() != context.Canceled { | |
| 43 | + | if contexts[0].Err() != context.Canceled { | |
| 49 | 44 | t.Fatal("old read not cancelled") | |
| 50 | 45 | } | |
| 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) | |
| 53 | 48 | if !r.Build(view) || !changed || name != "Ada" { | |
| 54 | 49 | t.Fatalf("paste: changed=%v value=%q", changed, name) | |
| 55 | 50 | } | |
| 56 | 51 | paste() | |
| 57 | - | c.callbacks[2]([]byte{0xff}, nil) | |
| 52 | + | callbacks[2]([]byte{0xff}, nil) | |
| 58 | 53 | r.Build(view) | |
| 59 | 54 | if name != "Ada" || changed { | |
| 60 | 55 | t.Fatal("invalid UTF-8 accepted") | |
| 61 | 56 | } | |
| 62 | 57 | paste() | |
| 63 | - | c.callbacks[3](make([]byte, edit.MaxClipboardBytes+1), nil) | |
| 58 | + | callbacks[3](make([]byte, edit.MaxClipboardBytes+1), nil) | |
| 64 | 59 | r.Build(view) | |
| 65 | 60 | if name != "Ada" || changed { | |
| 66 | 61 | t.Fatal("oversize accepted") | |
| 67 | 62 | } | |
| 68 | 63 | paste() | |
| 69 | - | c.callbacks[4](nil, errors.New("transfer failed")) | |
| 64 | + | callbacks[4](nil, errors.New("transfer failed")) | |
| 70 | 65 | r.Build(view) | |
| 71 | 66 | if debugDiagnostics && len(diagnostics) != 1 { | |
| 72 | 67 | 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 { | |||
| 65 | 65 | tb.Fatal(err) | |
| 66 | 66 | } | |
| 67 | 67 | r := newRuntime() | |
| 68 | - | stylesheet, err := os.ReadFile("../../examples/demo/app.css") | |
| 68 | + | stylesheet, err := os.ReadFile("testdata/demo.css") | |
| 69 | 69 | if err != nil { | |
| 70 | 70 | tb.Fatal(err) | |
| 71 | 71 | } | |
| @@ -92,7 +92,7 @@ func BenchmarkDemoFrame(b *testing.B) { | |||
| 92 | 92 | } | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | - | func TestDemoFrameAllocationBudget(t *testing.T) { | |
| 95 | + | func TestAllocDemoFrame(t *testing.T) { | |
| 96 | 96 | r := demoRuntime(t) | |
| 97 | 97 | if !r.Build(demoFrame) { | |
| 98 | 98 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 5 | "sync" | |
| 11 | 6 | ||
| 12 | - | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 13 | - | "github.com/bnema/nefergui/internal/presentation/session" | |
| 14 | - | "github.com/bnema/nefergui/internal/render" | |
| 15 | - | ||
| 16 | 7 | "github.com/bnema/nefergui/internal/css" | |
| 17 | 8 | "github.com/bnema/nefergui/internal/edit" | |
| 18 | 9 | "github.com/bnema/nefergui/internal/layout" | |
| @@ -52,9 +43,7 @@ type runtime struct { | |||
| 52 | 43 | pasteID uint64 | |
| 53 | 44 | ime edit.IME | |
| 54 | 45 | redraw bool | |
| 55 | - | consumed bool // the last committed build handled input events | |
| 56 | 46 | inputRects []Rect | |
| 57 | - | inputOut []wayland.Rect | |
| 58 | 47 | inputNil, inputStaged bool | |
| 59 | 48 | } | |
| 60 | 49 | ||
| @@ -186,7 +175,6 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 186 | 175 | if len(events) > 0 { | |
| 187 | 176 | r.redraw = true | |
| 188 | 177 | } | |
| 189 | - | r.consumed = len(events) > 0 | |
| 190 | 178 | // Release every losing editor before enabling the winner: the IME port | |
| 191 | 179 | // represents one seat, not one port per editor. Never depend on map order. | |
| 192 | 180 | for id, editor := range r.edits { | |
| @@ -247,13 +235,6 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 247 | 235 | return true | |
| 248 | 236 | } | |
| 249 | 237 | ||
| 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 | - | ||
| 257 | 238 | // Wait blocks until input/redraw is available or the context is cancelled. | |
| 258 | 239 | func (r *runtime) Wait(ctx context.Context) error { | |
| 259 | 240 | select { | |
| @@ -263,238 +244,3 @@ func (r *runtime) Wait(ctx context.Context) error { | |||
| 263 | 244 | return nil | |
| 264 | 245 | } | |
| 265 | 246 | } | |
| 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 @@ | |||
| 1 | 1 | // 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. | |
| 3 | 3 | package ui | |
| 4 | 4 | ||
| 5 | 5 | 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. | |
| 3 | 6 | // | |
| 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. | |
| 9 | 11 | // | |
| 10 | 12 | // The frame tree, controls, editors and input routing are implemented in | |
| 11 | 13 | // internal/ui; this package is the public facade and re-exports those types. | |
| 12 | 14 | // | |
| 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. | |
| 16 | 18 | package nefergui | |
| 17 | 19 | ||
| 18 | - | import ( | |
| 19 | - | "context" | |
| 20 | - | ||
| 21 | - | "github.com/bnema/nefergui/internal/ui" | |
| 22 | - | ) | |
| 20 | + | import "github.com/bnema/nefergui/internal/ui" | |
| 23 | 21 | ||
| 24 | 22 | // Frame is valid only during one call to the view function. Root builds the | |
| 25 | 23 | // application element; Diagnostics reports identity problems in debug builds. | |
| @@ -56,7 +54,6 @@ type ( | |||
| 56 | 54 | EditOption = ui.EditOption | |
| 57 | 55 | ValueOption = ui.ValueOption | |
| 58 | 56 | HeadingOption = ui.HeadingOption | |
| 59 | - | WindowOption = ui.WindowOption | |
| 60 | 57 | ) | |
| 61 | 58 | ||
| 62 | 59 | // commonOption belongs to every option family; disabledOption to every | |
| @@ -91,47 +88,17 @@ func Placeholder(v string) EditOption { return ui.Placeholder(v) } | |||
| 91 | 88 | // Password masks displayed editor text and blocks copy and cut. | |
| 92 | 89 | func Password(v bool) EditOption { return ui.Password(v) } | |
| 93 | 90 | ||
| 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 | |
| 102 | 94 | ||
| 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. | |
| 109 | 96 | 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 | |
| 118 | 99 | ) | |
| 119 | 100 | ||
| 120 | 101 | const ( | |
| 121 | - | LayerBackground = ui.LayerBackground | |
| 122 | - | LayerBottom = ui.LayerBottom | |
| 123 | - | LayerTop = ui.LayerTop | |
| 124 | - | LayerOverlay = ui.LayerOverlay | |
| 125 | - | ||
| 126 | - | AnchorTop = ui.AnchorTop | |
| 127 | - | AnchorBottom = ui.AnchorBottom | |
| 128 | - | AnchorLeft = ui.AnchorLeft | |
| 129 | - | AnchorRight = ui.AnchorRight | |
| 130 | - | ||
| 131 | - | KeyboardNone = ui.KeyboardNone | |
| 132 | - | KeyboardExclusive = ui.KeyboardExclusive | |
| 133 | - | KeyboardOnDemand = ui.KeyboardOnDemand | |
| 134 | - | ||
| 135 | 102 | InputPointerMotion = ui.InputPointerMotion | |
| 136 | 103 | InputPointerPress = ui.InputPointerPress | |
| 137 | 104 | InputPointerRelease = ui.InputPointerRelease | |
| @@ -144,61 +111,6 @@ const ( | |||
| 144 | 111 | ||
| 145 | 112 | ModShift = ui.ModShift | |
| 146 | 113 | ModCtrl = ui.ModCtrl | |
| 114 | + | ModAlt = ui.ModAlt | |
| 115 | + | ModSuper = ui.ModSuper | |
| 147 | 116 | ) | |
| 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) } | |