merge: layer-popups and packaging (layer pointer, popups, on-demand focus; Arch package with Ly session)
24 files · +916 -86
M .gitignore +1 -0
| @@ -4,3 +4,4 @@ | |||
| 4 | 4 | *.out | |
| 5 | 5 | coverage/ | |
| 6 | 6 | .worktrees/ | |
| 7 | + | /dist/ | |
| @@ -4,3 +4,4 @@ | |||||
| 4 | *.out | 4 | *.out | ||
| 5 | coverage/ | 5 | coverage/ | ||
| 6 | .worktrees/ | 6 | .worktrees/ | ||
| 7 | + | /dist/ | |||
M Makefile +39 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | .PHONY: build test vet race mocks mocks-check fakes-check arch check bin tty logs | |
| 1 | + | .PHONY: build test vet race mocks mocks-check fakes-check arch check bin tty logs dist pkg | |
| 2 | 2 | ||
| 3 | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | |
| 4 | 4 | TTY_TIMEOUT ?= 0 | |
| @@ -45,3 +45,41 @@ fakes-check: | |||
| 45 | 45 | arch: | |
| 46 | 46 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 47 | 47 | check: vet test arch fakes-check | |
| 48 | + | ||
| 49 | + | # Self-contained source tarball for packaging (packaging/arch/PKGBUILD): the | |
| 50 | + | # sibling purego-* modules are vendored, so the package builds offline. It | |
| 51 | + | # holds the working trees; the version ends in .dirty when one is not clean. | |
| 52 | + | SIBLINGS := github.com/bnema/purego-libwayland github.com/bnema/purego-vulkan | |
| 53 | + | SIBLING_DIRS = $(shell go list -m -f '{{.Dir}}' $(SIBLINGS)) | |
| 54 | + | dirty = $(shell for d in . $(SIBLING_DIRS); do git -C $$d diff --quiet HEAD && \ | |
| 55 | + | test -z "$$(git -C $$d ls-files -o --exclude-standard)" || { echo .dirty; break; }; done) | |
| 56 | + | # pacman-ordered: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | |
| 57 | + | VERSION ?= $(shell t=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//; s/-/_/g'); \ | |
| 58 | + | echo "$${t:-0.0.0}.r$$(git rev-list --count HEAD).g$$(git rev-parse --short HEAD)")$(dirty) | |
| 59 | + | DIST = dist/nefertty-$(VERSION) | |
| 60 | + | dist: SHELL := bash | |
| 61 | + | dist: .SHELLFLAGS := -eo pipefail -c | |
| 62 | + | # Computed once per dist run (git and go list), not for every target. | |
| 63 | + | ifneq ($(filter dist pkg,$(MAKECMDGOALS)),) | |
| 64 | + | VERSION := $(VERSION) | |
| 65 | + | endif | |
| 66 | + | dist: | |
| 67 | + | @for d in $(SIBLING_DIRS); do test -d $$d || { echo "sibling module not found: $$d" >&2; exit 1; }; done | |
| 68 | + | rm -rf $(DIST) && mkdir -p $(DIST) | |
| 69 | + | git ls-files -co --exclude-standard -z | tar --null -T - -c | tar -x -C $(DIST) | |
| 70 | + | cd $(DIST) && go mod edit $(foreach m,$(SIBLINGS),-replace=$(m)=$(shell go list -m -f '{{.Dir}}' $(m))) && go mod vendor | |
| 71 | + | cd $(DIST) && $(foreach m,$(SIBLINGS),sed -i 's#=> $(shell go list -m -f '{{.Dir}}' $(m))$$#=> ../$(notdir $(m))#' go.mod vendor/modules.txt &&) true | |
| 72 | + | @! grep -rqF "$(HOME)" $(DIST) || { echo "home path leaked into $(DIST)" >&2; exit 1; } | |
| 73 | + | echo '$(VERSION)' > $(DIST)/VERSION | |
| 74 | + | tar --owner=0 --group=0 --numeric-owner --sort=name --mtime=@$$(git log -1 --format=%ct) -C dist -czf $(DIST).tar.gz nefertty-$(VERSION) | |
| 75 | + | rm -rf $(DIST) | |
| 76 | + | @echo $(DIST).tar.gz | |
| 77 | + | ||
| 78 | + | # Arch package from the dist tarball, built outside the home directory so | |
| 79 | + | # no path of it lands in the package metadata. Install it with | |
| 80 | + | # `sudo pacman -U` on the printed file. | |
| 81 | + | pkg: dist | |
| 82 | + | d=$$(mktemp -d /tmp/nefertty-pkg.XXXXXX) && cp packaging/arch/PKGBUILD $(DIST).tar.gz $$d/ && \ | |
| 83 | + | cd $$d && sed -i "s/^pkgver=.*/pkgver=$(VERSION)/; s/^sha256sums=.*/sha256sums=('$$(sha256sum *.tar.gz | cut -d' ' -f1)')/" PKGBUILD && \ | |
| 84 | + | makepkg -f --noconfirm && mv *.pkg.tar.zst $(CURDIR)/dist/ && cd / && rm -rf $$d | |
| 85 | + | @ls dist/*.pkg.tar.zst | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | .PHONY: build test vet race mocks mocks-check fakes-check arch check bin tty logs | 1 | + | .PHONY: build test vet race mocks mocks-check fakes-check arch check bin tty logs dist pkg |
| 2 | 2 | ||||
| 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | ||
| 4 | TTY_TIMEOUT ?= 0 | 4 | TTY_TIMEOUT ?= 0 | ||
| @@ -45,3 +45,41 @@ fakes-check: | |||||
| 45 | arch: | 45 | arch: | ||
| 46 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | 46 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | ||
| 47 | check: vet test arch fakes-check | 47 | check: vet test arch fakes-check | ||
| 48 | + | ||||
| 49 | + | # Self-contained source tarball for packaging (packaging/arch/PKGBUILD): the | |||
| 50 | + | # sibling purego-* modules are vendored, so the package builds offline. It | |||
| 51 | + | # holds the working trees; the version ends in .dirty when one is not clean. | |||
| 52 | + | SIBLINGS := github.com/bnema/purego-libwayland github.com/bnema/purego-vulkan | |||
| 53 | + | SIBLING_DIRS = $(shell go list -m -f '{{.Dir}}' $(SIBLINGS)) | |||
| 54 | + | dirty = $(shell for d in . $(SIBLING_DIRS); do git -C $$d diff --quiet HEAD && \ | |||
| 55 | + | test -z "$$(git -C $$d ls-files -o --exclude-standard)" || { echo .dirty; break; }; done) | |||
| 56 | + | # pacman-ordered: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | |||
| 57 | + | VERSION ?= $(shell t=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//; s/-/_/g'); \ | |||
| 58 | + | echo "$${t:-0.0.0}.r$$(git rev-list --count HEAD).g$$(git rev-parse --short HEAD)")$(dirty) | |||
| 59 | + | DIST = dist/nefertty-$(VERSION) | |||
| 60 | + | dist: SHELL := bash | |||
| 61 | + | dist: .SHELLFLAGS := -eo pipefail -c | |||
| 62 | + | # Computed once per dist run (git and go list), not for every target. | |||
| 63 | + | ifneq ($(filter dist pkg,$(MAKECMDGOALS)),) | |||
| 64 | + | VERSION := $(VERSION) | |||
| 65 | + | endif | |||
| 66 | + | dist: | |||
| 67 | + | @for d in $(SIBLING_DIRS); do test -d $$d || { echo "sibling module not found: $$d" >&2; exit 1; }; done | |||
| 68 | + | rm -rf $(DIST) && mkdir -p $(DIST) | |||
| 69 | + | git ls-files -co --exclude-standard -z | tar --null -T - -c | tar -x -C $(DIST) | |||
| 70 | + | cd $(DIST) && go mod edit $(foreach m,$(SIBLINGS),-replace=$(m)=$(shell go list -m -f '{{.Dir}}' $(m))) && go mod vendor | |||
| 71 | + | cd $(DIST) && $(foreach m,$(SIBLINGS),sed -i 's#=> $(shell go list -m -f '{{.Dir}}' $(m))$$#=> ../$(notdir $(m))#' go.mod vendor/modules.txt &&) true | |||
| 72 | + | @! grep -rqF "$(HOME)" $(DIST) || { echo "home path leaked into $(DIST)" >&2; exit 1; } | |||
| 73 | + | echo '$(VERSION)' > $(DIST)/VERSION | |||
| 74 | + | tar --owner=0 --group=0 --numeric-owner --sort=name --mtime=@$$(git log -1 --format=%ct) -C dist -czf $(DIST).tar.gz nefertty-$(VERSION) | |||
| 75 | + | rm -rf $(DIST) | |||
| 76 | + | @echo $(DIST).tar.gz | |||
| 77 | + | ||||
| 78 | + | # Arch package from the dist tarball, built outside the home directory so | |||
| 79 | + | # no path of it lands in the package metadata. Install it with | |||
| 80 | + | # `sudo pacman -U` on the printed file. | |||
| 81 | + | pkg: dist | |||
| 82 | + | d=$$(mktemp -d /tmp/nefertty-pkg.XXXXXX) && cp packaging/arch/PKGBUILD $(DIST).tar.gz $$d/ && \ | |||
| 83 | + | cd $$d && sed -i "s/^pkgver=.*/pkgver=$(VERSION)/; s/^sha256sums=.*/sha256sums=('$$(sha256sum *.tar.gz | cut -d' ' -f1)')/" PKGBUILD && \ | |||
| 84 | + | makepkg -f --noconfirm && mv *.pkg.tar.zst $(CURDIR)/dist/ && cd / && rm -rf $$d | |||
| 85 | + | @ls dist/*.pkg.tar.zst | |||
M README.md +9 -0
| @@ -14,6 +14,15 @@ Build: `make build` (requires sibling checkouts of purego-libwayland and purego- | |||
| 14 | 14 | ||
| 15 | 15 | Run: `nefertty --backend=headless --timeout 5s` | |
| 16 | 16 | ||
| 17 | + | ## Install (Arch Linux) | |
| 18 | + | ||
| 19 | + | ```sh | |
| 20 | + | make pkg # builds dist/nefertty-*.pkg.tar.zst | |
| 21 | + | sudo pacman -U dist/nefertty-*.pkg.tar.zst | |
| 22 | + | ``` | |
| 23 | + | ||
| 24 | + | The package installs `nefertty` and a **NeferTTY** session for display managers (Ly, GDM, SDDM). The session runs `nefertty --session`, which shares `WAYLAND_DISPLAY` and `DISPLAY` with D-Bus and systemd user services, so portals and notification daemons reach it. `make dist` alone builds the self-contained source tarball (Go modules vendored) that the package uses; its version ends in `.dirty` when this repo or a sibling module has uncommitted changes. | |
| 25 | + | ||
| 17 | 26 | ## Configuration | |
| 18 | 27 | ||
| 19 | 28 | Configuration lives at `$XDG_CONFIG_HOME/nefertty/config` (or `~/.config/nefertty/config`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor. | |
| @@ -14,6 +14,15 @@ Build: `make build` (requires sibling checkouts of purego-libwayland and purego- | |||||
| 14 | 14 | ||||
| 15 | Run: `nefertty --backend=headless --timeout 5s` | 15 | Run: `nefertty --backend=headless --timeout 5s` | ||
| 16 | 16 | ||||
| 17 | + | ## Install (Arch Linux) | |||
| 18 | + | ||||
| 19 | + | ```sh | |||
| 20 | + | make pkg # builds dist/nefertty-*.pkg.tar.zst | |||
| 21 | + | sudo pacman -U dist/nefertty-*.pkg.tar.zst | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | The package installs `nefertty` and a **NeferTTY** session for display managers (Ly, GDM, SDDM). The session runs `nefertty --session`, which shares `WAYLAND_DISPLAY` and `DISPLAY` with D-Bus and systemd user services, so portals and notification daemons reach it. `make dist` alone builds the self-contained source tarball (Go modules vendored) that the package uses; its version ends in `.dirty` when this repo or a sibling module has uncommitted changes. | |||
| 25 | + | ||||
| 17 | ## Configuration | 26 | ## Configuration | ||
| 18 | 27 | ||||
| 19 | Configuration lives at `$XDG_CONFIG_HOME/nefertty/config` (or `~/.config/nefertty/config`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor. | 28 | Configuration lives at `$XDG_CONFIG_HOME/nefertty/config` (or `~/.config/nefertty/config`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor. | ||
M cmd/nefertty/main.go +11 -5
| @@ -23,6 +23,9 @@ import ( | |||
| 23 | 23 | ||
| 24 | 24 | type usageError struct{ error } | |
| 25 | 25 | ||
| 26 | + | // version is set with -ldflags "-X main.version=..." by packaged builds. | |
| 27 | + | var version string | |
| 28 | + | ||
| 26 | 29 | func main() { | |
| 27 | 30 | tuneGC() | |
| 28 | 31 | os.Exit(runCode()) | |
| @@ -99,11 +102,13 @@ func run() error { | |||
| 99 | 102 | fmt.Println("unknown") | |
| 100 | 103 | return nil | |
| 101 | 104 | } | |
| 102 | - | version := info.Main.Version | |
| 103 | - | if version == "" { | |
| 104 | - | version = "(devel)" | |
| 105 | + | v := info.Main.Version | |
| 106 | + | if version != "" { | |
| 107 | + | v = version // set by release builds (make dist) | |
| 108 | + | } else if v == "" { | |
| 109 | + | v = "(devel)" | |
| 105 | 110 | } | |
| 106 | - | fmt.Println(version) | |
| 111 | + | fmt.Println(v) | |
| 107 | 112 | return nil | |
| 108 | 113 | } | |
| 109 | 114 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |
| @@ -116,6 +121,7 @@ func run() error { | |||
| 116 | 121 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 117 | 122 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | |
| 118 | 123 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |
| 124 | + | session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services") | |
| 119 | 125 | if err := flags.Parse(os.Args[1:]); err != nil { | |
| 120 | 126 | if errors.Is(err, flag.ErrHelp) { | |
| 121 | 127 | return err | |
| @@ -221,7 +227,7 @@ func run() error { | |||
| 221 | 227 | for _, w := range warnings { | |
| 222 | 228 | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | |
| 223 | 229 | } | |
| 224 | - | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, ScreenshotDir: *screenshot, Sizes: sizes, Script: script}) | |
| 230 | + | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script}) | |
| 225 | 231 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 226 | 232 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 227 | 233 | err = nil | |
| @@ -23,6 +23,9 @@ import ( | |||||
| 23 | 23 | ||||
| 24 | type usageError struct{ error } | 24 | type usageError struct{ error } | ||
| 25 | 25 | ||||
| 26 | + | // version is set with -ldflags "-X main.version=..." by packaged builds. | |||
| 27 | + | var version string | |||
| 28 | + | ||||
| 26 | func main() { | 29 | func main() { | ||
| 27 | tuneGC() | 30 | tuneGC() | ||
| 28 | os.Exit(runCode()) | 31 | os.Exit(runCode()) | ||
| @@ -99,11 +102,13 @@ func run() error { | |||||
| 99 | fmt.Println("unknown") | 102 | fmt.Println("unknown") | ||
| 100 | return nil | 103 | return nil | ||
| 101 | } | 104 | } | ||
| 102 | - | version := info.Main.Version | 105 | + | v := info.Main.Version |
| 103 | - | if version == "" { | 106 | + | if version != "" { |
| 104 | - | version = "(devel)" | 107 | + | v = version // set by release builds (make dist) |
| 108 | + | } else if v == "" { | |||
| 109 | + | v = "(devel)" | |||
| 105 | } | 110 | } | ||
| 106 | - | fmt.Println(version) | 111 | + | fmt.Println(v) |
| 107 | return nil | 112 | return nil | ||
| 108 | } | 113 | } | ||
| 109 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | 114 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | ||
| @@ -116,6 +121,7 @@ func run() error { | |||||
| 116 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | 121 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | ||
| 117 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | 122 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | ||
| 118 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | 123 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | ||
| 124 | + | session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services") | |||
| 119 | if err := flags.Parse(os.Args[1:]); err != nil { | 125 | if err := flags.Parse(os.Args[1:]); err != nil { | ||
| 120 | if errors.Is(err, flag.ErrHelp) { | 126 | if errors.Is(err, flag.ErrHelp) { | ||
| 121 | return err | 127 | return err | ||
| @@ -221,7 +227,7 @@ func run() error { | |||||
| 221 | for _, w := range warnings { | 227 | for _, w := range warnings { | ||
| 222 | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | 228 | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | ||
| 223 | } | 229 | } | ||
| 224 | - | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, ScreenshotDir: *screenshot, Sizes: sizes, Script: script}) | 230 | + | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script}) |
| 225 | // SIGINT and SIGTERM cancel the context and are clean exits. | 231 | // SIGINT and SIGTERM cancel the context and are clean exits. | ||
| 226 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | 232 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | ||
| 227 | err = nil | 233 | err = nil | ||
M internal/adapters/launcher/launcher.go +1 -1
| @@ -32,7 +32,7 @@ func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int) | |||
| 32 | 32 | continue | |
| 33 | 33 | } | |
| 34 | 34 | switch { | |
| 35 | - | case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"), strings.HasPrefix(key, "XCURSOR_"): | |
| 35 | + | case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", key == "DBUS_SESSION_BUS_ADDRESS", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"), strings.HasPrefix(key, "XCURSOR_"): | |
| 36 | 36 | values[key] = value | |
| 37 | 37 | } | |
| 38 | 38 | } | |
| @@ -32,7 +32,7 @@ func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int) | |||||
| 32 | continue | 32 | continue | ||
| 33 | } | 33 | } | ||
| 34 | switch { | 34 | switch { | ||
| 35 | - | case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"), strings.HasPrefix(key, "XCURSOR_"): | 35 | + | case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", key == "DBUS_SESSION_BUS_ADDRESS", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"), strings.HasPrefix(key, "XCURSOR_"): |
| 36 | values[key] = value | 36 | values[key] = value | ||
| 37 | } | 37 | } | ||
| 38 | } | 38 | } | ||
M internal/adapters/launcher/launcher_test.go +2 -2
| @@ -14,8 +14,8 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | func TestChildEnv(t *testing.T) { | |
| 17 | - | got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad"}, "wayland-7", "/run/me", 24) | |
| 18 | - | want := []string{"HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XCURSOR_PATH=/icons", "XCURSOR_SIZE=24", "XCURSOR_THEME=Adwaita", "XDG_CURRENT_DESKTOP=nefertty", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"} | |
| 17 | + | got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus"}, "wayland-7", "/run/me", 24) | |
| 18 | + | want := []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus", "HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XCURSOR_PATH=/icons", "XCURSOR_SIZE=24", "XCURSOR_THEME=Adwaita", "XDG_CURRENT_DESKTOP=nefertty", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"} | |
| 19 | 19 | if !reflect.DeepEqual(got, want) { | |
| 20 | 20 | t.Fatalf("got %v want %v", got, want) | |
| 21 | 21 | } | |
| @@ -14,8 +14,8 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | func TestChildEnv(t *testing.T) { | 16 | func TestChildEnv(t *testing.T) { | ||
| 17 | - | got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad"}, "wayland-7", "/run/me", 24) | 17 | + | got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus"}, "wayland-7", "/run/me", 24) |
| 18 | - | want := []string{"HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XCURSOR_PATH=/icons", "XCURSOR_SIZE=24", "XCURSOR_THEME=Adwaita", "XDG_CURRENT_DESKTOP=nefertty", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"} | 18 | + | want := []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus", "HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XCURSOR_PATH=/icons", "XCURSOR_SIZE=24", "XCURSOR_THEME=Adwaita", "XDG_CURRENT_DESKTOP=nefertty", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"} |
| 19 | if !reflect.DeepEqual(got, want) { | 19 | if !reflect.DeepEqual(got, want) { | ||
| 20 | t.Fatalf("got %v want %v", got, want) | 20 | t.Fatalf("got %v want %v", got, want) | ||
| 21 | } | 21 | } | ||
A internal/adapters/launcher/session.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package launcher | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os/exec" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // sessionVars are the displays D-Bus and systemd user services (portals, | |
| 12 | + | // notification daemons) need to reach the session. | |
| 13 | + | var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"} | |
| 14 | + | ||
| 15 | + | // ExportSession shares the session's displays with D-Bus and systemd user | |
| 16 | + | // services, and waits for it: clients started next may activate them. | |
| 17 | + | func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) { | |
| 18 | + | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 19 | + | defer cancel() | |
| 20 | + | runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // UnexportSession withdraws the displays from the systemd user manager on | |
| 24 | + | // exit, so services started after logout do not reach a dead display. | |
| 25 | + | func UnexportSession(env []string, log zerowrap.Logger) { | |
| 26 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 27 | + | defer cancel() | |
| 28 | + | path, err := childLookPath("systemctl", env) | |
| 29 | + | if err != nil { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | cmd := exec.CommandContext(ctx, path, append([]string{"--user", "unset-environment"}, sessionVars...)...) | |
| 33 | + | cmd.Env = env | |
| 34 | + | if err := cmd.Run(); err != nil { | |
| 35 | + | log.Warn().Err(err).Msg("session unexport failed") | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) { | |
| 40 | + | path, err := childLookPath("dbus-update-activation-environment", env) | |
| 41 | + | if err != nil { | |
| 42 | + | log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found") | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | cmd := exec.CommandContext(ctx, path, args...) | |
| 46 | + | cmd.Env = env | |
| 47 | + | if err := cmd.Run(); err != nil { | |
| 48 | + | log.Warn().Err(err).Msg("session export failed") | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | log.Info().Strs("vars", sessionVars).Msg(done) | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package launcher | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os/exec" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // sessionVars are the displays D-Bus and systemd user services (portals, | |||
| 12 | + | // notification daemons) need to reach the session. | |||
| 13 | + | var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"} | |||
| 14 | + | ||||
| 15 | + | // ExportSession shares the session's displays with D-Bus and systemd user | |||
| 16 | + | // services, and waits for it: clients started next may activate them. | |||
| 17 | + | func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) { | |||
| 18 | + | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |||
| 19 | + | defer cancel() | |||
| 20 | + | runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // UnexportSession withdraws the displays from the systemd user manager on | |||
| 24 | + | // exit, so services started after logout do not reach a dead display. | |||
| 25 | + | func UnexportSession(env []string, log zerowrap.Logger) { | |||
| 26 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 27 | + | defer cancel() | |||
| 28 | + | path, err := childLookPath("systemctl", env) | |||
| 29 | + | if err != nil { | |||
| 30 | + | return | |||
| 31 | + | } | |||
| 32 | + | cmd := exec.CommandContext(ctx, path, append([]string{"--user", "unset-environment"}, sessionVars...)...) | |||
| 33 | + | cmd.Env = env | |||
| 34 | + | if err := cmd.Run(); err != nil { | |||
| 35 | + | log.Warn().Err(err).Msg("session unexport failed") | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) { | |||
| 40 | + | path, err := childLookPath("dbus-update-activation-environment", env) | |||
| 41 | + | if err != nil { | |||
| 42 | + | log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found") | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | cmd := exec.CommandContext(ctx, path, args...) | |||
| 46 | + | cmd.Env = env | |||
| 47 | + | if err := cmd.Run(); err != nil { | |||
| 48 | + | log.Warn().Err(err).Msg("session export failed") | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | log.Info().Strs("vars", sessionVars).Msg(done) | |||
| 52 | + | } | |||
A internal/adapters/launcher/session_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package launcher | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefertty/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // The session commands run with the child environment and the right | |
| 14 | + | // arguments; the scripts on PATH record them. | |
| 15 | + | func TestExportSession(t *testing.T) { | |
| 16 | + | dir := t.TempDir() | |
| 17 | + | out := filepath.Join(dir, "calls") | |
| 18 | + | script := "#!/bin/sh\necho \"${0##*/} $* $WAYLAND_DISPLAY\" >> " + out + "\n" | |
| 19 | + | for _, name := range []string{"dbus-update-activation-environment", "systemctl"} { | |
| 20 | + | if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | env := []string{"PATH=" + dir, "WAYLAND_DISPLAY=wayland-9"} | |
| 25 | + | ctx := context.Background() | |
| 26 | + | ExportSession(ctx, env, logging.For(ctx, "launcher")) | |
| 27 | + | UnexportSession(env, logging.For(ctx, "launcher")) | |
| 28 | + | data, err := os.ReadFile(out) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" + | |
| 33 | + | "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" | |
| 34 | + | if string(data) != want { | |
| 35 | + | t.Fatalf("calls:\n%s", data) | |
| 36 | + | } | |
| 37 | + | // Missing tools are skipped, not fatal. | |
| 38 | + | ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher")) | |
| 39 | + | if strings.Count(string(data), "\n") != 2 { | |
| 40 | + | t.Fatal(string(data)) | |
| 41 | + | } | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package launcher | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // The session commands run with the child environment and the right | |||
| 14 | + | // arguments; the scripts on PATH record them. | |||
| 15 | + | func TestExportSession(t *testing.T) { | |||
| 16 | + | dir := t.TempDir() | |||
| 17 | + | out := filepath.Join(dir, "calls") | |||
| 18 | + | script := "#!/bin/sh\necho \"${0##*/} $* $WAYLAND_DISPLAY\" >> " + out + "\n" | |||
| 19 | + | for _, name := range []string{"dbus-update-activation-environment", "systemctl"} { | |||
| 20 | + | if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | env := []string{"PATH=" + dir, "WAYLAND_DISPLAY=wayland-9"} | |||
| 25 | + | ctx := context.Background() | |||
| 26 | + | ExportSession(ctx, env, logging.For(ctx, "launcher")) | |||
| 27 | + | UnexportSession(env, logging.For(ctx, "launcher")) | |||
| 28 | + | data, err := os.ReadFile(out) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" + | |||
| 33 | + | "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" | |||
| 34 | + | if string(data) != want { | |||
| 35 | + | t.Fatalf("calls:\n%s", data) | |||
| 36 | + | } | |||
| 37 | + | // Missing tools are skipped, not fatal. | |||
| 38 | + | ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher")) | |||
| 39 | + | if strings.Count(string(data), "\n") != 2 { | |||
| 40 | + | t.Fatal(string(data)) | |||
| 41 | + | } | |||
| 42 | + | } | |||
M internal/adapters/vulkan/renderer.go +12 -3
| @@ -515,9 +515,6 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 515 | 515 | } | |
| 516 | 516 | x, y := w.Rect.X, w.Rect.Y | |
| 517 | 517 | if w.Popup { | |
| 518 | - | // Menus draw only what the client drew, shadows clipped. | |
| 519 | - | content := contents[w.ID] | |
| 520 | - | place(&content, x, y, w.Rect.W, w.Rect.H) | |
| 521 | 518 | continue | |
| 522 | 519 | } | |
| 523 | 520 | // Content sits inside the border; core sized the client to match. | |
| @@ -551,7 +548,19 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 551 | 548 | } | |
| 552 | 549 | } | |
| 553 | 550 | } | |
| 551 | + | // Popups draw only what the client drew, shadows clipped. Window popups | |
| 552 | + | // stay with the windows; a layer's go over every layer. | |
| 553 | + | addPopups := func(overLayers bool) { | |
| 554 | + | for _, w := range s.Windows { | |
| 555 | + | if w.Popup && w.OverLayers == overLayers && !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 { | |
| 556 | + | content := contents[w.ID] | |
| 557 | + | place(&content, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H) | |
| 558 | + | } | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | addPopups(false) | |
| 554 | 562 | addLayers(true) | |
| 563 | + | addPopups(true) | |
| 555 | 564 | if err := r.ensureStaging(used); err != nil { | |
| 556 | 565 | return err | |
| 557 | 566 | } | |
| @@ -515,9 +515,6 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 515 | } | 515 | } | ||
| 516 | x, y := w.Rect.X, w.Rect.Y | 516 | x, y := w.Rect.X, w.Rect.Y | ||
| 517 | if w.Popup { | 517 | if w.Popup { | ||
| 518 | - | // Menus draw only what the client drew, shadows clipped. | |||
| 519 | - | content := contents[w.ID] | |||
| 520 | - | place(&content, x, y, w.Rect.W, w.Rect.H) | |||
| 521 | continue | 518 | continue | ||
| 522 | } | 519 | } | ||
| 523 | // Content sits inside the border; core sized the client to match. | 520 | // Content sits inside the border; core sized the client to match. | ||
| @@ -551,7 +548,19 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 551 | } | 548 | } | ||
| 552 | } | 549 | } | ||
| 553 | } | 550 | } | ||
| 551 | + | // Popups draw only what the client drew, shadows clipped. Window popups | |||
| 552 | + | // stay with the windows; a layer's go over every layer. | |||
| 553 | + | addPopups := func(overLayers bool) { | |||
| 554 | + | for _, w := range s.Windows { | |||
| 555 | + | if w.Popup && w.OverLayers == overLayers && !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 { | |||
| 556 | + | content := contents[w.ID] | |||
| 557 | + | place(&content, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H) | |||
| 558 | + | } | |||
| 559 | + | } | |||
| 560 | + | } | |||
| 561 | + | addPopups(false) | |||
| 554 | addLayers(true) | 562 | addLayers(true) | ||
| 563 | + | addPopups(true) | |||
| 555 | if err := r.ensureStaging(used); err != nil { | 564 | if err := r.ensureStaging(used); err != nil { | ||
| 556 | return err | 565 | return err | ||
| 557 | } | 566 | } | ||
M internal/adapters/wayland/cursor.go +4 -3
| @@ -100,10 +100,11 @@ func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, | |||
| 100 | 100 | } | |
| 101 | 101 | func (pointer) Release(r *wayland.Pointer) { r.Destroy() } | |
| 102 | 102 | ||
| 103 | - | // hasPointerFocus reports whether the client owns the window under the pointer. | |
| 103 | + | // hasPointerFocus reports whether the client owns the window or layer | |
| 104 | + | // under the pointer. | |
| 104 | 105 | func (s *Server) hasPointerFocus(c server.Client) bool { | |
| 105 | - | w := s.windows[s.pointerFocus] | |
| 106 | - | return w != nil && w.mapped && w.xdg.resource.Client() == c | |
| 106 | + | owner, _, ok := s.pointerTarget(s.pointerFocus) | |
| 107 | + | return ok && owner == c | |
| 107 | 108 | } | |
| 108 | 109 | ||
| 109 | 110 | // cursorImage copies the committed shm buffer of a cursor surface. No | |
| @@ -100,10 +100,11 @@ func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, | |||||
| 100 | } | 100 | } | ||
| 101 | func (pointer) Release(r *wayland.Pointer) { r.Destroy() } | 101 | func (pointer) Release(r *wayland.Pointer) { r.Destroy() } | ||
| 102 | 102 | ||||
| 103 | - | // hasPointerFocus reports whether the client owns the window under the pointer. | 103 | + | // hasPointerFocus reports whether the client owns the window or layer |
| 104 | + | // under the pointer. | |||
| 104 | func (s *Server) hasPointerFocus(c server.Client) bool { | 105 | func (s *Server) hasPointerFocus(c server.Client) bool { | ||
| 105 | - | w := s.windows[s.pointerFocus] | 106 | + | owner, _, ok := s.pointerTarget(s.pointerFocus) |
| 106 | - | return w != nil && w.mapped && w.xdg.resource.Client() == c | 107 | + | return ok && owner == c |
| 107 | } | 108 | } | ||
| 108 | 109 | ||||
| 109 | // cursorImage copies the committed shm buffer of a cursor surface. No | 110 | // cursorImage copies the committed shm buffer of a cursor surface. No | ||
M internal/adapters/wayland/globals.go +6 -5
| @@ -361,11 +361,12 @@ func (h seat) GetPointer(r *wayland.Seat, id uint32) { | |||
| 361 | 361 | s.pointers[r.Client()] = list | |
| 362 | 362 | } | |
| 363 | 363 | } | |
| 364 | - | if w := s.windows[s.pointerFocus]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() { | |
| 365 | - | s.serial++ | |
| 366 | - | x, y := w.surfacePoint(s.pointerX, s.pointerY) | |
| 367 | - | p.SendEnter(s.serial, w.xdg.surfaceResource(), server.FixedFromFloat(x), server.FixedFromFloat(y)) | |
| 368 | - | pointerFrame(p) | |
| 364 | + | if s.hasPointerFocus(r.Client()) { | |
| 365 | + | if surf, _, x, y := s.pointerSurface(s.pointerFocus, s.pointerX, s.pointerY); surf != nil { | |
| 366 | + | s.serial++ | |
| 367 | + | p.SendEnter(s.serial, surf, server.FixedFromFloat(x), server.FixedFromFloat(y)) | |
| 368 | + | pointerFrame(p) | |
| 369 | + | } | |
| 369 | 370 | } | |
| 370 | 371 | } | |
| 371 | 372 | func (h seat) GetKeyboard(r *wayland.Seat, id uint32) { | |
| @@ -361,11 +361,12 @@ func (h seat) GetPointer(r *wayland.Seat, id uint32) { | |||||
| 361 | s.pointers[r.Client()] = list | 361 | s.pointers[r.Client()] = list | ||
| 362 | } | 362 | } | ||
| 363 | } | 363 | } | ||
| 364 | - | if w := s.windows[s.pointerFocus]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() { | 364 | + | if s.hasPointerFocus(r.Client()) { |
| 365 | - | s.serial++ | 365 | + | if surf, _, x, y := s.pointerSurface(s.pointerFocus, s.pointerX, s.pointerY); surf != nil { |
| 366 | - | x, y := w.surfacePoint(s.pointerX, s.pointerY) | 366 | + | s.serial++ |
| 367 | - | p.SendEnter(s.serial, w.xdg.surfaceResource(), server.FixedFromFloat(x), server.FixedFromFloat(y)) | 367 | + | p.SendEnter(s.serial, surf, server.FixedFromFloat(x), server.FixedFromFloat(y)) |
| 368 | - | pointerFrame(p) | 368 | + | pointerFrame(p) |
| 369 | + | } | |||
| 369 | } | 370 | } | ||
| 370 | } | 371 | } | ||
| 371 | func (h seat) GetKeyboard(r *wayland.Seat, id uint32) { | 372 | func (h seat) GetKeyboard(r *wayland.Seat, id uint32) { | ||
M internal/adapters/wayland/layer.go +28 -2
| @@ -106,8 +106,31 @@ func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfac | |||
| 106 | 106 | l.pending.keyboard = k | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | - | // Layer popups are deferred; layer-shell is capped at v4. | |
| 110 | - | func (*layerSurface) GetPopup(*wlrlayershell.ZwlrLayerSurfaceV1, *xdgshell.Popup) {} | |
| 109 | + | // GetPopup makes the layer surface the parent of an xdg_popup created | |
| 110 | + | // without one. Core places it relative to the layer. | |
| 111 | + | func (l *layerSurface) GetPopup(_ *wlrlayershell.ZwlrLayerSurfaceV1, r *xdgshell.Popup) { | |
| 112 | + | if r == nil { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | for _, w := range l.shell.server.windows { | |
| 116 | + | if p := w.popup; p != nil && p.resource.Resource == r.Resource { | |
| 117 | + | if p.parent == nil && !p.sent && !p.done { | |
| 118 | + | p.layer = l | |
| 119 | + | } | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // surfaceResource is the layer's live wl_surface, if any. | |
| 126 | + | func (l *layerSurface) surfaceResource() *wayland.Surface { | |
| 127 | + | for resource, state := range l.shell.server.surfaces { | |
| 128 | + | if state == l.surface && resource.Alive() { | |
| 129 | + | return wayland.WrapSurface(resource) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return nil | |
| 133 | + | } | |
| 111 | 134 | func (l *layerSurface) AckConfigure(r *wlrlayershell.ZwlrLayerSurfaceV1, serial uint32) { | |
| 112 | 135 | for i, v := range l.serials { | |
| 113 | 136 | if v == serial { | |
| @@ -136,6 +159,9 @@ func (l *layerSurface) unmap() { | |||
| 136 | 159 | if s := l.shell.server; s.focused == l.id { | |
| 137 | 160 | s.changeFocus(0) | |
| 138 | 161 | } | |
| 162 | + | if s := l.shell.server; s.pointerFocus == l.id { | |
| 163 | + | s.changePointerFocus(0, 0, 0) | |
| 164 | + | } | |
| 139 | 165 | l.mapped = false | |
| 140 | 166 | l.configured = false | |
| 141 | 167 | l.acked = false | |
| @@ -106,8 +106,31 @@ func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfac | |||||
| 106 | l.pending.keyboard = k | 106 | l.pending.keyboard = k | ||
| 107 | } | 107 | } | ||
| 108 | 108 | ||||
| 109 | - | // Layer popups are deferred; layer-shell is capped at v4. | 109 | + | // GetPopup makes the layer surface the parent of an xdg_popup created |
| 110 | - | func (*layerSurface) GetPopup(*wlrlayershell.ZwlrLayerSurfaceV1, *xdgshell.Popup) {} | 110 | + | // without one. Core places it relative to the layer. |
| 111 | + | func (l *layerSurface) GetPopup(_ *wlrlayershell.ZwlrLayerSurfaceV1, r *xdgshell.Popup) { | |||
| 112 | + | if r == nil { | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | for _, w := range l.shell.server.windows { | |||
| 116 | + | if p := w.popup; p != nil && p.resource.Resource == r.Resource { | |||
| 117 | + | if p.parent == nil && !p.sent && !p.done { | |||
| 118 | + | p.layer = l | |||
| 119 | + | } | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // surfaceResource is the layer's live wl_surface, if any. | |||
| 126 | + | func (l *layerSurface) surfaceResource() *wayland.Surface { | |||
| 127 | + | for resource, state := range l.shell.server.surfaces { | |||
| 128 | + | if state == l.surface && resource.Alive() { | |||
| 129 | + | return wayland.WrapSurface(resource) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 111 | func (l *layerSurface) AckConfigure(r *wlrlayershell.ZwlrLayerSurfaceV1, serial uint32) { | 134 | func (l *layerSurface) AckConfigure(r *wlrlayershell.ZwlrLayerSurfaceV1, serial uint32) { | ||
| 112 | for i, v := range l.serials { | 135 | for i, v := range l.serials { | ||
| 113 | if v == serial { | 136 | if v == serial { | ||
| @@ -136,6 +159,9 @@ func (l *layerSurface) unmap() { | |||||
| 136 | if s := l.shell.server; s.focused == l.id { | 159 | if s := l.shell.server; s.focused == l.id { | ||
| 137 | s.changeFocus(0) | 160 | s.changeFocus(0) | ||
| 138 | } | 161 | } | ||
| 162 | + | if s := l.shell.server; s.pointerFocus == l.id { | |||
| 163 | + | s.changePointerFocus(0, 0, 0) | |||
| 164 | + | } | |||
| 139 | l.mapped = false | 165 | l.mapped = false | ||
| 140 | l.configured = false | 166 | l.configured = false | ||
| 141 | l.acked = false | 167 | l.acked = false | ||
M internal/adapters/wayland/layer_test.go +125 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "github.com/bnema/nefertty/internal/ports" | |
| 6 | 6 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 7 | 7 | "github.com/bnema/purego-libwayland/protocol/wlrlayershell" | |
| 8 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |
| 8 | 9 | "github.com/bnema/wlturbo" | |
| 9 | 10 | "golang.org/x/sys/unix" | |
| 10 | 11 | "os" | |
| @@ -128,7 +129,16 @@ func TestWaybarLayer(t *testing.T) { | |||
| 128 | 129 | if err = os.WriteFile(style, nil, 0600); err != nil { | |
| 129 | 130 | t.Fatal(err) | |
| 130 | 131 | } | |
| 131 | - | _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home}, "-c", config, "-s", style) | |
| 132 | + | // waybar (gio) aborts without a session bus; the user bus is the default. | |
| 133 | + | bus := os.Getenv("DBUS_SESSION_BUS_ADDRESS") | |
| 134 | + | if bus == "" { | |
| 135 | + | sock := os.Getenv("XDG_RUNTIME_DIR") + "/bus" | |
| 136 | + | if _, err := os.Stat(sock); err != nil { | |
| 137 | + | t.Skip("no D-Bus session bus") | |
| 138 | + | } | |
| 139 | + | bus = "unix:path=" + sock | |
| 140 | + | } | |
| 141 | + | _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home, "DBUS_SESSION_BUS_ADDRESS=" + bus}, "-c", config, "-s", style) | |
| 132 | 142 | select { | |
| 133 | 143 | case ev := <-events: | |
| 134 | 144 | v, ok := ev.(ports.LayerChanged) | |
| @@ -222,3 +232,117 @@ func TestXDGOutputDone(t *testing.T) { | |||
| 222 | 232 | }) | |
| 223 | 233 | } | |
| 224 | 234 | } | |
| 235 | + | ||
| 236 | + | type layerPointerProxy struct { | |
| 237 | + | wlturbo.BaseProxy | |
| 238 | + | enters chan [2]float64 | |
| 239 | + | buttons chan uint32 // serials | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func (p *layerPointerProxy) Dispatch(e *wlturbo.Event) { | |
| 243 | + | switch e.Opcode { | |
| 244 | + | case uint16(wayland.PointerEventEnter): | |
| 245 | + | _, _ = e.Uint32(), e.Uint32() | |
| 246 | + | p.enters <- [2]float64{e.Fixed().Float64(), e.Fixed().Float64()} | |
| 247 | + | case uint16(wayland.PointerEventButton): | |
| 248 | + | p.buttons <- e.Uint32() | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // A bar gets the pointer, and a menu it opens on a click through | |
| 253 | + | // get_popup reaches core with the bar as its parent and a grab. | |
| 254 | + | func TestLayerPointerAndPopup(t *testing.T) { | |
| 255 | + | s, events, commands, dir := lifecycleServer(t) | |
| 256 | + | c := protocolClient(t, s, dir) | |
| 257 | + | seat := bindVersion(t, c, "wl_seat", 8) | |
| 258 | + | registerProtocol(t, c, seat) | |
| 259 | + | pointer := c.AllocateID() | |
| 260 | + | pp := &layerPointerProxy{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)} | |
| 261 | + | pp.SetID(pointer) | |
| 262 | + | c.Context().Register(pp) | |
| 263 | + | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | |
| 264 | + | comp := bindProtocol(t, c, "wl_compositor") | |
| 265 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |
| 266 | + | shm := bindProtocol(t, c, "wl_shm") | |
| 267 | + | registerProtocol(t, c, shm) | |
| 268 | + | fd, err := unix.MemfdCreate("layer-popup-buffer", 0) | |
| 269 | + | if err != nil { | |
| 270 | + | t.Fatal(err) | |
| 271 | + | } | |
| 272 | + | defer unix.Close(fd) | |
| 273 | + | if err = unix.Ftruncate(fd, 4); err != nil { | |
| 274 | + | t.Fatal(err) | |
| 275 | + | } | |
| 276 | + | pool, buf := c.AllocateID(), c.AllocateID() | |
| 277 | + | registerProtocol(t, c, pool) | |
| 278 | + | registerProtocol(t, c, buf) | |
| 279 | + | if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | |
| 280 | + | t.Fatal(err) | |
| 281 | + | } | |
| 282 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | |
| 283 | + | ||
| 284 | + | surf, layer := layerProtocol(t, c) | |
| 285 | + | lp := &layerProxy{configured: make(chan [3]uint32, 2)} | |
| 286 | + | lp.SetID(layer) | |
| 287 | + | c.Context().Register(lp) | |
| 288 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1)) | |
| 289 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 290 | + | if err = c.Roundtrip(); err != nil { | |
| 291 | + | t.Fatal(err) | |
| 292 | + | } | |
| 293 | + | var config [3]uint32 | |
| 294 | + | select { | |
| 295 | + | case config = <-lp.configured: | |
| 296 | + | case <-time.After(2 * time.Second): | |
| 297 | + | t.Fatal("configure timeout") | |
| 298 | + | } | |
| 299 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, config[0]) | |
| 300 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |
| 301 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 302 | + | if err = c.Roundtrip(); err != nil { | |
| 303 | + | t.Fatal(err) | |
| 304 | + | } | |
| 305 | + | lc, ok := waitEvent(t, events, 2*time.Second).(ports.LayerChanged) | |
| 306 | + | if !ok || len(lc.Layers) != 1 { | |
| 307 | + | t.Fatalf("layer mapped %+v", lc) | |
| 308 | + | } | |
| 309 | + | id := lc.Layers[0].ID | |
| 310 | + | ||
| 311 | + | commands <- ports.PointerFocus{ID: id, X: 0.5, Y: 0.25} | |
| 312 | + | commands <- ports.PointerButtonTo{ID: id, Button: 0x110, Pressed: true} | |
| 313 | + | var press uint32 | |
| 314 | + | for deadline := time.Now().Add(2 * time.Second); len(pp.buttons) == 0; time.Sleep(5 * time.Millisecond) { | |
| 315 | + | if time.Now().After(deadline) { | |
| 316 | + | t.Fatal("pointer events timeout") | |
| 317 | + | } | |
| 318 | + | if err = c.Roundtrip(); err != nil { | |
| 319 | + | t.Fatal(err) | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | if at := <-pp.enters; at != [2]float64{0.5, 0.25} { | |
| 323 | + | t.Fatalf("enter at %v", at) | |
| 324 | + | } | |
| 325 | + | press = <-pp.buttons | |
| 326 | + | ||
| 327 | + | positioner := c.AllocateID() | |
| 328 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | |
| 329 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(1), int32(1)) | |
| 330 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetAnchorRect, int32(0), int32(0), int32(1), int32(1)) | |
| 331 | + | done := make(chan struct{}, 1) | |
| 332 | + | xdgWindow(t, c, comp, wm, buf, func(pxdg uint32) { | |
| 333 | + | popup := c.AllocateID() | |
| 334 | + | proxy := &popupDoneProxy{done: done} | |
| 335 | + | proxy.SetID(popup) | |
| 336 | + | c.Context().Register(proxy) | |
| 337 | + | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner) | |
| 338 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestGetPopup, popup) | |
| 339 | + | requestProtocol(t, c, popup, xdgshell.PopupRequestGrab, seat, press) | |
| 340 | + | }) | |
| 341 | + | req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest) | |
| 342 | + | if !ok || req.Parent != id || !req.Grab { | |
| 343 | + | t.Fatalf("popup request %+v, want parent %d with a grab", req, id) | |
| 344 | + | } | |
| 345 | + | if len(done) != 0 { | |
| 346 | + | t.Fatal("layer popup dismissed") | |
| 347 | + | } | |
| 348 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "github.com/bnema/nefertty/internal/ports" | 5 | "github.com/bnema/nefertty/internal/ports" | ||
| 6 | "github.com/bnema/purego-libwayland/protocol/wayland" | 6 | "github.com/bnema/purego-libwayland/protocol/wayland" | ||
| 7 | "github.com/bnema/purego-libwayland/protocol/wlrlayershell" | 7 | "github.com/bnema/purego-libwayland/protocol/wlrlayershell" | ||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 8 | "github.com/bnema/wlturbo" | 9 | "github.com/bnema/wlturbo" | ||
| 9 | "golang.org/x/sys/unix" | 10 | "golang.org/x/sys/unix" | ||
| 10 | "os" | 11 | "os" | ||
| @@ -128,7 +129,16 @@ func TestWaybarLayer(t *testing.T) { | |||||
| 128 | if err = os.WriteFile(style, nil, 0600); err != nil { | 129 | if err = os.WriteFile(style, nil, 0600); err != nil { | ||
| 129 | t.Fatal(err) | 130 | t.Fatal(err) | ||
| 130 | } | 131 | } | ||
| 131 | - | _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home}, "-c", config, "-s", style) | 132 | + | // waybar (gio) aborts without a session bus; the user bus is the default. |
| 133 | + | bus := os.Getenv("DBUS_SESSION_BUS_ADDRESS") | |||
| 134 | + | if bus == "" { | |||
| 135 | + | sock := os.Getenv("XDG_RUNTIME_DIR") + "/bus" | |||
| 136 | + | if _, err := os.Stat(sock); err != nil { | |||
| 137 | + | t.Skip("no D-Bus session bus") | |||
| 138 | + | } | |||
| 139 | + | bus = "unix:path=" + sock | |||
| 140 | + | } | |||
| 141 | + | _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + home, "DBUS_SESSION_BUS_ADDRESS=" + bus}, "-c", config, "-s", style) | |||
| 132 | select { | 142 | select { | ||
| 133 | case ev := <-events: | 143 | case ev := <-events: | ||
| 134 | v, ok := ev.(ports.LayerChanged) | 144 | v, ok := ev.(ports.LayerChanged) | ||
| @@ -222,3 +232,117 @@ func TestXDGOutputDone(t *testing.T) { | |||||
| 222 | }) | 232 | }) | ||
| 223 | } | 233 | } | ||
| 224 | } | 234 | } | ||
| 235 | + | ||||
| 236 | + | type layerPointerProxy struct { | |||
| 237 | + | wlturbo.BaseProxy | |||
| 238 | + | enters chan [2]float64 | |||
| 239 | + | buttons chan uint32 // serials | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func (p *layerPointerProxy) Dispatch(e *wlturbo.Event) { | |||
| 243 | + | switch e.Opcode { | |||
| 244 | + | case uint16(wayland.PointerEventEnter): | |||
| 245 | + | _, _ = e.Uint32(), e.Uint32() | |||
| 246 | + | p.enters <- [2]float64{e.Fixed().Float64(), e.Fixed().Float64()} | |||
| 247 | + | case uint16(wayland.PointerEventButton): | |||
| 248 | + | p.buttons <- e.Uint32() | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // A bar gets the pointer, and a menu it opens on a click through | |||
| 253 | + | // get_popup reaches core with the bar as its parent and a grab. | |||
| 254 | + | func TestLayerPointerAndPopup(t *testing.T) { | |||
| 255 | + | s, events, commands, dir := lifecycleServer(t) | |||
| 256 | + | c := protocolClient(t, s, dir) | |||
| 257 | + | seat := bindVersion(t, c, "wl_seat", 8) | |||
| 258 | + | registerProtocol(t, c, seat) | |||
| 259 | + | pointer := c.AllocateID() | |||
| 260 | + | pp := &layerPointerProxy{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)} | |||
| 261 | + | pp.SetID(pointer) | |||
| 262 | + | c.Context().Register(pp) | |||
| 263 | + | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | |||
| 264 | + | comp := bindProtocol(t, c, "wl_compositor") | |||
| 265 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |||
| 266 | + | shm := bindProtocol(t, c, "wl_shm") | |||
| 267 | + | registerProtocol(t, c, shm) | |||
| 268 | + | fd, err := unix.MemfdCreate("layer-popup-buffer", 0) | |||
| 269 | + | if err != nil { | |||
| 270 | + | t.Fatal(err) | |||
| 271 | + | } | |||
| 272 | + | defer unix.Close(fd) | |||
| 273 | + | if err = unix.Ftruncate(fd, 4); err != nil { | |||
| 274 | + | t.Fatal(err) | |||
| 275 | + | } | |||
| 276 | + | pool, buf := c.AllocateID(), c.AllocateID() | |||
| 277 | + | registerProtocol(t, c, pool) | |||
| 278 | + | registerProtocol(t, c, buf) | |||
| 279 | + | if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | |||
| 280 | + | t.Fatal(err) | |||
| 281 | + | } | |||
| 282 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | |||
| 283 | + | ||||
| 284 | + | surf, layer := layerProtocol(t, c) | |||
| 285 | + | lp := &layerProxy{configured: make(chan [3]uint32, 2)} | |||
| 286 | + | lp.SetID(layer) | |||
| 287 | + | c.Context().Register(lp) | |||
| 288 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1)) | |||
| 289 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 290 | + | if err = c.Roundtrip(); err != nil { | |||
| 291 | + | t.Fatal(err) | |||
| 292 | + | } | |||
| 293 | + | var config [3]uint32 | |||
| 294 | + | select { | |||
| 295 | + | case config = <-lp.configured: | |||
| 296 | + | case <-time.After(2 * time.Second): | |||
| 297 | + | t.Fatal("configure timeout") | |||
| 298 | + | } | |||
| 299 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, config[0]) | |||
| 300 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |||
| 301 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 302 | + | if err = c.Roundtrip(); err != nil { | |||
| 303 | + | t.Fatal(err) | |||
| 304 | + | } | |||
| 305 | + | lc, ok := waitEvent(t, events, 2*time.Second).(ports.LayerChanged) | |||
| 306 | + | if !ok || len(lc.Layers) != 1 { | |||
| 307 | + | t.Fatalf("layer mapped %+v", lc) | |||
| 308 | + | } | |||
| 309 | + | id := lc.Layers[0].ID | |||
| 310 | + | ||||
| 311 | + | commands <- ports.PointerFocus{ID: id, X: 0.5, Y: 0.25} | |||
| 312 | + | commands <- ports.PointerButtonTo{ID: id, Button: 0x110, Pressed: true} | |||
| 313 | + | var press uint32 | |||
| 314 | + | for deadline := time.Now().Add(2 * time.Second); len(pp.buttons) == 0; time.Sleep(5 * time.Millisecond) { | |||
| 315 | + | if time.Now().After(deadline) { | |||
| 316 | + | t.Fatal("pointer events timeout") | |||
| 317 | + | } | |||
| 318 | + | if err = c.Roundtrip(); err != nil { | |||
| 319 | + | t.Fatal(err) | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | if at := <-pp.enters; at != [2]float64{0.5, 0.25} { | |||
| 323 | + | t.Fatalf("enter at %v", at) | |||
| 324 | + | } | |||
| 325 | + | press = <-pp.buttons | |||
| 326 | + | ||||
| 327 | + | positioner := c.AllocateID() | |||
| 328 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | |||
| 329 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(1), int32(1)) | |||
| 330 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetAnchorRect, int32(0), int32(0), int32(1), int32(1)) | |||
| 331 | + | done := make(chan struct{}, 1) | |||
| 332 | + | xdgWindow(t, c, comp, wm, buf, func(pxdg uint32) { | |||
| 333 | + | popup := c.AllocateID() | |||
| 334 | + | proxy := &popupDoneProxy{done: done} | |||
| 335 | + | proxy.SetID(popup) | |||
| 336 | + | c.Context().Register(proxy) | |||
| 337 | + | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner) | |||
| 338 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestGetPopup, popup) | |||
| 339 | + | requestProtocol(t, c, popup, xdgshell.PopupRequestGrab, seat, press) | |||
| 340 | + | }) | |||
| 341 | + | req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest) | |||
| 342 | + | if !ok || req.Parent != id || !req.Grab { | |||
| 343 | + | t.Fatalf("popup request %+v, want parent %d with a grab", req, id) | |||
| 344 | + | } | |||
| 345 | + | if len(done) != 0 { | |||
| 346 | + | t.Fatal("layer popup dismissed") | |||
| 347 | + | } | |||
| 348 | + | } | |||
M internal/adapters/wayland/popup.go +33 -16
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "github.com/bnema/nefertty/internal/ports" | |
| 5 | 5 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 6 | 6 | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |
| 7 | + | "github.com/bnema/purego-libwayland/server" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // popup is the xdg_popup role of a window. Core places it (PopupRequest → | |
| @@ -13,8 +14,11 @@ type popup struct { | |||
| 13 | 14 | w *window | |
| 14 | 15 | resource *xdgshell.Popup | |
| 15 | 16 | parent *window | |
| 16 | - | pos ports.Positioner | |
| 17 | - | grab bool | |
| 17 | + | // layer is the parent set through zwlr_layer_surface_v1.get_popup, | |
| 18 | + | // for a popup created without an xdg parent. | |
| 19 | + | layer *layerSurface | |
| 20 | + | pos ports.Positioner | |
| 21 | + | grab bool | |
| 18 | 22 | // sent is set once core got the PopupRequest; done once dismissed. | |
| 19 | 23 | sent, done bool | |
| 20 | 24 | } | |
| @@ -42,11 +46,6 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||
| 42 | 46 | s.nextWindow++ | |
| 43 | 47 | s.windows[w.id] = w | |
| 44 | 48 | x.window = w | |
| 45 | - | // Without a parent (xdg-shell lets clients set it through another | |
| 46 | - | // protocol, such as layer-shell) the popup has nowhere to go. | |
| 47 | - | if parent == nil { | |
| 48 | - | pp.dismiss() | |
| 49 | - | } | |
| 50 | 49 | x.surface.role = func(buffer bool) { | |
| 51 | 50 | if x.surface.destroyed { | |
| 52 | 51 | w.unmap() | |
| @@ -54,14 +53,21 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||
| 54 | 53 | } | |
| 55 | 54 | switch { | |
| 56 | 55 | case !buffer && !w.mapped && !x.configured && !pp.done: | |
| 56 | + | // Without a parent (xdg-shell lets clients set it through | |
| 57 | + | // layer-shell before the first commit) the popup has nowhere | |
| 58 | + | // to go. | |
| 59 | + | if pp.parentID() == 0 { | |
| 60 | + | pp.dismiss() | |
| 61 | + | return | |
| 62 | + | } | |
| 57 | 63 | if !pp.sent { | |
| 58 | 64 | pp.sent = true | |
| 59 | - | s.emit(ports.PopupRequest{ID: w.id, Parent: parent.id, Positioner: pp.pos, Grab: pp.grab}) | |
| 65 | + | s.emit(ports.PopupRequest{ID: w.id, Parent: pp.parentID(), Positioner: pp.pos, Grab: pp.grab}) | |
| 60 | 66 | } | |
| 61 | 67 | case buffer && !w.mapped && x.acked && !pp.done: | |
| 62 | 68 | w.mapped = true | |
| 63 | 69 | s.emit(ports.PopupMapped{ID: w.id}) | |
| 64 | - | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(parent.id)).Msg("popup mapped") | |
| 70 | + | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped") | |
| 65 | 71 | case !buffer && w.mapped: | |
| 66 | 72 | // Unmapped, it may map again after a new initial commit. | |
| 67 | 73 | w.unmap() | |
| @@ -88,6 +94,17 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||
| 88 | 94 | ||
| 89 | 95 | func (*popup) Destroy(*xdgshell.Popup) {} | |
| 90 | 96 | ||
| 97 | + | // parentID is the window or layer surface the popup hangs from, 0 if none. | |
| 98 | + | func (p *popup) parentID() ports.WindowID { | |
| 99 | + | switch { | |
| 100 | + | case p.parent != nil: | |
| 101 | + | return p.parent.id | |
| 102 | + | case p.layer != nil: | |
| 103 | + | return p.layer.id | |
| 104 | + | } | |
| 105 | + | return 0 | |
| 106 | + | } | |
| 107 | + | ||
| 91 | 108 | // Grab is only valid before the first commit; core then gives the popup the | |
| 92 | 109 | // keyboard and closes it on a click outside its popup chain. | |
| 93 | 110 | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |
| @@ -99,7 +116,7 @@ func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |||
| 99 | 116 | // and a popup over a popup grabs only if its parent does. | |
| 100 | 117 | s := p.w.xdg.server | |
| 101 | 118 | fresh := serial == s.press && s.pressClient == r.Client() | |
| 102 | - | if !fresh || p.parent == nil || !s.topGrab(p.parent) { | |
| 119 | + | if !fresh || p.parentID() == 0 || !s.topGrab(r.Client(), p.parent) { | |
| 103 | 120 | p.dismiss() | |
| 104 | 121 | return | |
| 105 | 122 | } | |
| @@ -114,7 +131,7 @@ func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token ui | |||
| 114 | 131 | } | |
| 115 | 132 | p.pos = next | |
| 116 | 133 | if p.sent { | |
| 117 | - | s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parent.id, Positioner: next, Grab: p.grab, Reposition: true, Token: token}) | |
| 134 | + | s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parentID(), Positioner: next, Grab: p.grab, Reposition: true, Token: token}) | |
| 118 | 135 | } | |
| 119 | 136 | } | |
| 120 | 137 | ||
| @@ -138,10 +155,10 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||
| 138 | 155 | } | |
| 139 | 156 | } | |
| 140 | 157 | ||
| 141 | - | // topGrab reports whether a grabbing popup may go on parent: parent is | |
| 142 | - | // the client's newest live grabbing popup, or a toplevel when it has none. | |
| 143 | - | func (s *Server) topGrab(parent *window) bool { | |
| 144 | - | client := parent.xdg.resource.Client() | |
| 158 | + | // topGrab reports whether a grabbing popup of client may go on parent: | |
| 159 | + | // parent is the client's newest live grabbing popup, or a toplevel (nil | |
| 160 | + | // for a layer surface) when it has none. | |
| 161 | + | func (s *Server) topGrab(client server.Client, parent *window) bool { | |
| 145 | 162 | var top *window | |
| 146 | 163 | for _, w := range s.windows { | |
| 147 | 164 | if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) { | |
| @@ -149,7 +166,7 @@ func (s *Server) topGrab(parent *window) bool { | |||
| 149 | 166 | } | |
| 150 | 167 | } | |
| 151 | 168 | if top == nil { | |
| 152 | - | return parent.popup == nil | |
| 169 | + | return parent == nil || parent.popup == nil | |
| 153 | 170 | } | |
| 154 | 171 | return top == parent | |
| 155 | 172 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "github.com/bnema/nefertty/internal/ports" | 4 | "github.com/bnema/nefertty/internal/ports" | ||
| 5 | "github.com/bnema/purego-libwayland/protocol/wayland" | 5 | "github.com/bnema/purego-libwayland/protocol/wayland" | ||
| 6 | "github.com/bnema/purego-libwayland/protocol/xdgshell" | 6 | "github.com/bnema/purego-libwayland/protocol/xdgshell" | ||
| 7 | + | "github.com/bnema/purego-libwayland/server" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // popup is the xdg_popup role of a window. Core places it (PopupRequest → | 10 | // popup is the xdg_popup role of a window. Core places it (PopupRequest → | ||
| @@ -13,8 +14,11 @@ type popup struct { | |||||
| 13 | w *window | 14 | w *window | ||
| 14 | resource *xdgshell.Popup | 15 | resource *xdgshell.Popup | ||
| 15 | parent *window | 16 | parent *window | ||
| 16 | - | pos ports.Positioner | 17 | + | // layer is the parent set through zwlr_layer_surface_v1.get_popup, |
| 17 | - | grab bool | 18 | + | // for a popup created without an xdg parent. |
| 19 | + | layer *layerSurface | |||
| 20 | + | pos ports.Positioner | |||
| 21 | + | grab bool | |||
| 18 | // sent is set once core got the PopupRequest; done once dismissed. | 22 | // sent is set once core got the PopupRequest; done once dismissed. | ||
| 19 | sent, done bool | 23 | sent, done bool | ||
| 20 | } | 24 | } | ||
| @@ -42,11 +46,6 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||||
| 42 | s.nextWindow++ | 46 | s.nextWindow++ | ||
| 43 | s.windows[w.id] = w | 47 | s.windows[w.id] = w | ||
| 44 | x.window = w | 48 | x.window = w | ||
| 45 | - | // Without a parent (xdg-shell lets clients set it through another | |||
| 46 | - | // protocol, such as layer-shell) the popup has nowhere to go. | |||
| 47 | - | if parent == nil { | |||
| 48 | - | pp.dismiss() | |||
| 49 | - | } | |||
| 50 | x.surface.role = func(buffer bool) { | 49 | x.surface.role = func(buffer bool) { | ||
| 51 | if x.surface.destroyed { | 50 | if x.surface.destroyed { | ||
| 52 | w.unmap() | 51 | w.unmap() | ||
| @@ -54,14 +53,21 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||||
| 54 | } | 53 | } | ||
| 55 | switch { | 54 | switch { | ||
| 56 | case !buffer && !w.mapped && !x.configured && !pp.done: | 55 | case !buffer && !w.mapped && !x.configured && !pp.done: | ||
| 56 | + | // Without a parent (xdg-shell lets clients set it through | |||
| 57 | + | // layer-shell before the first commit) the popup has nowhere | |||
| 58 | + | // to go. | |||
| 59 | + | if pp.parentID() == 0 { | |||
| 60 | + | pp.dismiss() | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 57 | if !pp.sent { | 63 | if !pp.sent { | ||
| 58 | pp.sent = true | 64 | pp.sent = true | ||
| 59 | - | s.emit(ports.PopupRequest{ID: w.id, Parent: parent.id, Positioner: pp.pos, Grab: pp.grab}) | 65 | + | s.emit(ports.PopupRequest{ID: w.id, Parent: pp.parentID(), Positioner: pp.pos, Grab: pp.grab}) |
| 60 | } | 66 | } | ||
| 61 | case buffer && !w.mapped && x.acked && !pp.done: | 67 | case buffer && !w.mapped && x.acked && !pp.done: | ||
| 62 | w.mapped = true | 68 | w.mapped = true | ||
| 63 | s.emit(ports.PopupMapped{ID: w.id}) | 69 | s.emit(ports.PopupMapped{ID: w.id}) | ||
| 64 | - | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(parent.id)).Msg("popup mapped") | 70 | + | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped") |
| 65 | case !buffer && w.mapped: | 71 | case !buffer && w.mapped: | ||
| 66 | // Unmapped, it may map again after a new initial commit. | 72 | // Unmapped, it may map again after a new initial commit. | ||
| 67 | w.unmap() | 73 | w.unmap() | ||
| @@ -88,6 +94,17 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||||
| 88 | 94 | ||||
| 89 | func (*popup) Destroy(*xdgshell.Popup) {} | 95 | func (*popup) Destroy(*xdgshell.Popup) {} | ||
| 90 | 96 | ||||
| 97 | + | // parentID is the window or layer surface the popup hangs from, 0 if none. | |||
| 98 | + | func (p *popup) parentID() ports.WindowID { | |||
| 99 | + | switch { | |||
| 100 | + | case p.parent != nil: | |||
| 101 | + | return p.parent.id | |||
| 102 | + | case p.layer != nil: | |||
| 103 | + | return p.layer.id | |||
| 104 | + | } | |||
| 105 | + | return 0 | |||
| 106 | + | } | |||
| 107 | + | ||||
| 91 | // Grab is only valid before the first commit; core then gives the popup the | 108 | // Grab is only valid before the first commit; core then gives the popup the | ||
| 92 | // keyboard and closes it on a click outside its popup chain. | 109 | // keyboard and closes it on a click outside its popup chain. | ||
| 93 | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | 110 | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | ||
| @@ -99,7 +116,7 @@ func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |||||
| 99 | // and a popup over a popup grabs only if its parent does. | 116 | // and a popup over a popup grabs only if its parent does. | ||
| 100 | s := p.w.xdg.server | 117 | s := p.w.xdg.server | ||
| 101 | fresh := serial == s.press && s.pressClient == r.Client() | 118 | fresh := serial == s.press && s.pressClient == r.Client() | ||
| 102 | - | if !fresh || p.parent == nil || !s.topGrab(p.parent) { | 119 | + | if !fresh || p.parentID() == 0 || !s.topGrab(r.Client(), p.parent) { |
| 103 | p.dismiss() | 120 | p.dismiss() | ||
| 104 | return | 121 | return | ||
| 105 | } | 122 | } | ||
| @@ -114,7 +131,7 @@ func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token ui | |||||
| 114 | } | 131 | } | ||
| 115 | p.pos = next | 132 | p.pos = next | ||
| 116 | if p.sent { | 133 | if p.sent { | ||
| 117 | - | s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parent.id, Positioner: next, Grab: p.grab, Reposition: true, Token: token}) | 134 | + | s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parentID(), Positioner: next, Grab: p.grab, Reposition: true, Token: token}) |
| 118 | } | 135 | } | ||
| 119 | } | 136 | } | ||
| 120 | 137 | ||||
| @@ -138,10 +155,10 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||||
| 138 | } | 155 | } | ||
| 139 | } | 156 | } | ||
| 140 | 157 | ||||
| 141 | - | // topGrab reports whether a grabbing popup may go on parent: parent is | 158 | + | // topGrab reports whether a grabbing popup of client may go on parent: |
| 142 | - | // the client's newest live grabbing popup, or a toplevel when it has none. | 159 | + | // parent is the client's newest live grabbing popup, or a toplevel (nil |
| 143 | - | func (s *Server) topGrab(parent *window) bool { | 160 | + | // for a layer surface) when it has none. |
| 144 | - | client := parent.xdg.resource.Client() | 161 | + | func (s *Server) topGrab(client server.Client, parent *window) bool { |
| 145 | var top *window | 162 | var top *window | ||
| 146 | for _, w := range s.windows { | 163 | for _, w := range s.windows { | ||
| 147 | if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) { | 164 | if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) { | ||
| @@ -149,7 +166,7 @@ func (s *Server) topGrab(parent *window) bool { | |||||
| 149 | } | 166 | } | ||
| 150 | } | 167 | } | ||
| 151 | if top == nil { | 168 | if top == nil { | ||
| 152 | - | return parent.popup == nil | 169 | + | return parent == nil || parent.popup == nil |
| 153 | } | 170 | } | ||
| 154 | return top == parent | 171 | return top == parent | ||
| 155 | } | 172 | } | ||
M internal/adapters/wayland/server.go +67 -25
| @@ -363,6 +363,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 363 | 363 | case ports.PointerFocus: | |
| 364 | 364 | s.changePointerFocus(c.ID, c.X, c.Y) | |
| 365 | 365 | case ports.PointerMotionTo: | |
| 366 | + | if l := s.layers[c.ID]; l != nil && c.ID == s.pointerFocus { | |
| 367 | + | s.pointerX, s.pointerY = c.X, c.Y | |
| 368 | + | for _, p := range s.layerPointers(l) { | |
| 369 | + | p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y)) | |
| 370 | + | pointerFrame(p) | |
| 371 | + | } | |
| 372 | + | return | |
| 373 | + | } | |
| 366 | 374 | if w := s.windows[c.ID]; w != nil && c.ID == s.pointerFocus { | |
| 367 | 375 | if s.relativeMotion(w, c) && s.locked(w) { | |
| 368 | 376 | for _, p := range s.windowPointers(w) { | |
| @@ -386,14 +394,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 386 | 394 | } | |
| 387 | 395 | case ports.PointerButtonTo: | |
| 388 | 396 | // Releases may target the implicit-grab window after focus has moved. | |
| 389 | - | if w := s.windows[c.ID]; w != nil { | |
| 397 | + | if client, pointers, ok := s.pointerTarget(c.ID); ok { | |
| 390 | 398 | s.serial++ | |
| 391 | 399 | state := uint32(0) | |
| 392 | 400 | if c.Pressed { | |
| 393 | 401 | state = 1 | |
| 394 | - | s.press, s.pressClient, s.pressAt = s.serial, w.xdg.resource.Client(), time.Now() | |
| 402 | + | s.press, s.pressClient, s.pressAt = s.serial, client, time.Now() | |
| 395 | 403 | } | |
| 396 | - | for _, p := range s.windowPointers(w) { | |
| 404 | + | for _, p := range pointers { | |
| 397 | 405 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | |
| 398 | 406 | pointerFrame(p) | |
| 399 | 407 | } | |
| @@ -404,7 +412,8 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 404 | 412 | return | |
| 405 | 413 | } | |
| 406 | 414 | steps, values := s.wheelSteps(c.Axis) | |
| 407 | - | for _, p := range s.windowPointers(s.windows[c.ID]) { | |
| 415 | + | _, pointers, _ := s.pointerTarget(c.ID) | |
| 416 | + | for _, p := range pointers { | |
| 408 | 417 | sendAxis(p, c.Axis, steps, values) | |
| 409 | 418 | } | |
| 410 | 419 | case ports.SetKeymap: | |
| @@ -506,15 +515,42 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke | |||
| 506 | 515 | } | |
| 507 | 516 | } | |
| 508 | 517 | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | |
| 509 | - | for resource, state := range s.surfaces { | |
| 510 | - | if state == l.surface && resource.Alive() { | |
| 511 | - | return wayland.WrapSurface(resource), s.clientKeyboards(l.resource.Client()) | |
| 512 | - | } | |
| 518 | + | if surf := l.surfaceResource(); surf != nil { | |
| 519 | + | return surf, s.clientKeyboards(l.resource.Client()) | |
| 513 | 520 | } | |
| 514 | 521 | } | |
| 515 | 522 | return nil, nil | |
| 516 | 523 | } | |
| 517 | 524 | ||
| 525 | + | // pointerTarget resolves a mapped window or layer ID to its client and | |
| 526 | + | // live pointers. | |
| 527 | + | func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Pointer, bool) { | |
| 528 | + | if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() { | |
| 529 | + | return w.xdg.resource.Client(), s.windowPointers(w), true | |
| 530 | + | } | |
| 531 | + | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | |
| 532 | + | return l.resource.Client(), s.layerPointers(l), true | |
| 533 | + | } | |
| 534 | + | return server.Client{}, nil, false | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | // pointerSurface is the wl_surface of a mapped window or layer and the | |
| 538 | + | // point in its surface coordinates. | |
| 539 | + | func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surface, []*wayland.Pointer, float64, float64) { | |
| 540 | + | if w := s.windows[id]; w != nil { | |
| 541 | + | if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() { | |
| 542 | + | x, y := w.surfacePoint(x, y) | |
| 543 | + | return surf, s.windowPointers(w), x, y | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | if l := s.layers[id]; l != nil && l.mapped { | |
| 547 | + | if surf := l.surfaceResource(); surf != nil { | |
| 548 | + | return surf, s.layerPointers(l), x, y | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | return nil, nil, 0, 0 | |
| 552 | + | } | |
| 553 | + | ||
| 518 | 554 | // keymapFile writes a sealed memfd holding the NUL-terminated keymap. | |
| 519 | 555 | func keymapFile(keymap string) (int, uint32, error) { | |
| 520 | 556 | if uint64(len(keymap))+1 > uint64(^uint32(0)) { | |
| @@ -698,8 +734,19 @@ func (s *Server) windowPointers(w *window) []*wayland.Pointer { | |||
| 698 | 734 | if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() { | |
| 699 | 735 | return nil | |
| 700 | 736 | } | |
| 737 | + | return s.clientPointers(w.xdg.resource.Client()) | |
| 738 | + | } | |
| 739 | + | ||
| 740 | + | func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer { | |
| 741 | + | if l == nil || !l.mapped || !l.resource.Resource.Alive() { | |
| 742 | + | return nil | |
| 743 | + | } | |
| 744 | + | return s.clientPointers(l.resource.Client()) | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | func (s *Server) clientPointers(c server.Client) []*wayland.Pointer { | |
| 701 | 748 | var result []*wayland.Pointer | |
| 702 | - | for _, p := range s.pointers[w.xdg.resource.Client()] { | |
| 749 | + | for _, p := range s.pointers[c] { | |
| 703 | 750 | if p.Resource.Alive() { | |
| 704 | 751 | result = append(result, p) | |
| 705 | 752 | } | |
| @@ -714,25 +761,20 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | |||
| 714 | 761 | // The new client sets its own cursor on enter; until then, the arrow. | |
| 715 | 762 | s.cursorSurface = nil | |
| 716 | 763 | s.setCursor(ports.CursorChange{}) | |
| 717 | - | if old := s.windows[s.pointerFocus]; old != nil { | |
| 718 | - | if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | |
| 719 | - | for _, p := range s.windowPointers(old) { | |
| 720 | - | s.serial++ | |
| 721 | - | p.SendLeave(s.serial, surface) | |
| 722 | - | pointerFrame(p) | |
| 723 | - | } | |
| 764 | + | if surface, pointers, _, _ := s.pointerSurface(s.pointerFocus, 0, 0); surface != nil { | |
| 765 | + | for _, p := range pointers { | |
| 766 | + | s.serial++ | |
| 767 | + | p.SendLeave(s.serial, surface) | |
| 768 | + | pointerFrame(p) | |
| 724 | 769 | } | |
| 725 | 770 | } | |
| 726 | 771 | s.pointerFocus = 0 | |
| 727 | - | if w := s.windows[id]; w != nil { | |
| 728 | - | if surface := w.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | |
| 729 | - | s.pointerFocus, s.pointerX, s.pointerY = id, x, y | |
| 730 | - | x, y := w.surfacePoint(x, y) | |
| 731 | - | for _, p := range s.windowPointers(w) { | |
| 732 | - | s.serial++ | |
| 733 | - | p.SendEnter(s.serial, surface, server.FixedFromFloat(x), server.FixedFromFloat(y)) | |
| 734 | - | pointerFrame(p) | |
| 735 | - | } | |
| 772 | + | if surface, pointers, sx, sy := s.pointerSurface(id, x, y); surface != nil { | |
| 773 | + | s.pointerFocus, s.pointerX, s.pointerY = id, x, y | |
| 774 | + | for _, p := range pointers { | |
| 775 | + | s.serial++ | |
| 776 | + | p.SendEnter(s.serial, surface, server.FixedFromFloat(sx), server.FixedFromFloat(sy)) | |
| 777 | + | pointerFrame(p) | |
| 736 | 778 | } | |
| 737 | 779 | } | |
| 738 | 780 | s.updateConstraint() | |
| @@ -363,6 +363,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 363 | case ports.PointerFocus: | 363 | case ports.PointerFocus: | ||
| 364 | s.changePointerFocus(c.ID, c.X, c.Y) | 364 | s.changePointerFocus(c.ID, c.X, c.Y) | ||
| 365 | case ports.PointerMotionTo: | 365 | case ports.PointerMotionTo: | ||
| 366 | + | if l := s.layers[c.ID]; l != nil && c.ID == s.pointerFocus { | |||
| 367 | + | s.pointerX, s.pointerY = c.X, c.Y | |||
| 368 | + | for _, p := range s.layerPointers(l) { | |||
| 369 | + | p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y)) | |||
| 370 | + | pointerFrame(p) | |||
| 371 | + | } | |||
| 372 | + | return | |||
| 373 | + | } | |||
| 366 | if w := s.windows[c.ID]; w != nil && c.ID == s.pointerFocus { | 374 | if w := s.windows[c.ID]; w != nil && c.ID == s.pointerFocus { | ||
| 367 | if s.relativeMotion(w, c) && s.locked(w) { | 375 | if s.relativeMotion(w, c) && s.locked(w) { | ||
| 368 | for _, p := range s.windowPointers(w) { | 376 | for _, p := range s.windowPointers(w) { | ||
| @@ -386,14 +394,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 386 | } | 394 | } | ||
| 387 | case ports.PointerButtonTo: | 395 | case ports.PointerButtonTo: | ||
| 388 | // Releases may target the implicit-grab window after focus has moved. | 396 | // Releases may target the implicit-grab window after focus has moved. | ||
| 389 | - | if w := s.windows[c.ID]; w != nil { | 397 | + | if client, pointers, ok := s.pointerTarget(c.ID); ok { |
| 390 | s.serial++ | 398 | s.serial++ | ||
| 391 | state := uint32(0) | 399 | state := uint32(0) | ||
| 392 | if c.Pressed { | 400 | if c.Pressed { | ||
| 393 | state = 1 | 401 | state = 1 | ||
| 394 | - | s.press, s.pressClient, s.pressAt = s.serial, w.xdg.resource.Client(), time.Now() | 402 | + | s.press, s.pressClient, s.pressAt = s.serial, client, time.Now() |
| 395 | } | 403 | } | ||
| 396 | - | for _, p := range s.windowPointers(w) { | 404 | + | for _, p := range pointers { |
| 397 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | 405 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | ||
| 398 | pointerFrame(p) | 406 | pointerFrame(p) | ||
| 399 | } | 407 | } | ||
| @@ -404,7 +412,8 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 404 | return | 412 | return | ||
| 405 | } | 413 | } | ||
| 406 | steps, values := s.wheelSteps(c.Axis) | 414 | steps, values := s.wheelSteps(c.Axis) | ||
| 407 | - | for _, p := range s.windowPointers(s.windows[c.ID]) { | 415 | + | _, pointers, _ := s.pointerTarget(c.ID) |
| 416 | + | for _, p := range pointers { | |||
| 408 | sendAxis(p, c.Axis, steps, values) | 417 | sendAxis(p, c.Axis, steps, values) | ||
| 409 | } | 418 | } | ||
| 410 | case ports.SetKeymap: | 419 | case ports.SetKeymap: | ||
| @@ -506,15 +515,42 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke | |||||
| 506 | } | 515 | } | ||
| 507 | } | 516 | } | ||
| 508 | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | 517 | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | ||
| 509 | - | for resource, state := range s.surfaces { | 518 | + | if surf := l.surfaceResource(); surf != nil { |
| 510 | - | if state == l.surface && resource.Alive() { | 519 | + | return surf, s.clientKeyboards(l.resource.Client()) |
| 511 | - | return wayland.WrapSurface(resource), s.clientKeyboards(l.resource.Client()) | |||
| 512 | - | } | |||
| 513 | } | 520 | } | ||
| 514 | } | 521 | } | ||
| 515 | return nil, nil | 522 | return nil, nil | ||
| 516 | } | 523 | } | ||
| 517 | 524 | ||||
| 525 | + | // pointerTarget resolves a mapped window or layer ID to its client and | |||
| 526 | + | // live pointers. | |||
| 527 | + | func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Pointer, bool) { | |||
| 528 | + | if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() { | |||
| 529 | + | return w.xdg.resource.Client(), s.windowPointers(w), true | |||
| 530 | + | } | |||
| 531 | + | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | |||
| 532 | + | return l.resource.Client(), s.layerPointers(l), true | |||
| 533 | + | } | |||
| 534 | + | return server.Client{}, nil, false | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | // pointerSurface is the wl_surface of a mapped window or layer and the | |||
| 538 | + | // point in its surface coordinates. | |||
| 539 | + | func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surface, []*wayland.Pointer, float64, float64) { | |||
| 540 | + | if w := s.windows[id]; w != nil { | |||
| 541 | + | if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() { | |||
| 542 | + | x, y := w.surfacePoint(x, y) | |||
| 543 | + | return surf, s.windowPointers(w), x, y | |||
| 544 | + | } | |||
| 545 | + | } | |||
| 546 | + | if l := s.layers[id]; l != nil && l.mapped { | |||
| 547 | + | if surf := l.surfaceResource(); surf != nil { | |||
| 548 | + | return surf, s.layerPointers(l), x, y | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | return nil, nil, 0, 0 | |||
| 552 | + | } | |||
| 553 | + | ||||
| 518 | // keymapFile writes a sealed memfd holding the NUL-terminated keymap. | 554 | // keymapFile writes a sealed memfd holding the NUL-terminated keymap. | ||
| 519 | func keymapFile(keymap string) (int, uint32, error) { | 555 | func keymapFile(keymap string) (int, uint32, error) { | ||
| 520 | if uint64(len(keymap))+1 > uint64(^uint32(0)) { | 556 | if uint64(len(keymap))+1 > uint64(^uint32(0)) { | ||
| @@ -698,8 +734,19 @@ func (s *Server) windowPointers(w *window) []*wayland.Pointer { | |||||
| 698 | if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() { | 734 | if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() { | ||
| 699 | return nil | 735 | return nil | ||
| 700 | } | 736 | } | ||
| 737 | + | return s.clientPointers(w.xdg.resource.Client()) | |||
| 738 | + | } | |||
| 739 | + | ||||
| 740 | + | func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer { | |||
| 741 | + | if l == nil || !l.mapped || !l.resource.Resource.Alive() { | |||
| 742 | + | return nil | |||
| 743 | + | } | |||
| 744 | + | return s.clientPointers(l.resource.Client()) | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | func (s *Server) clientPointers(c server.Client) []*wayland.Pointer { | |||
| 701 | var result []*wayland.Pointer | 748 | var result []*wayland.Pointer | ||
| 702 | - | for _, p := range s.pointers[w.xdg.resource.Client()] { | 749 | + | for _, p := range s.pointers[c] { |
| 703 | if p.Resource.Alive() { | 750 | if p.Resource.Alive() { | ||
| 704 | result = append(result, p) | 751 | result = append(result, p) | ||
| 705 | } | 752 | } | ||
| @@ -714,25 +761,20 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | |||||
| 714 | // The new client sets its own cursor on enter; until then, the arrow. | 761 | // The new client sets its own cursor on enter; until then, the arrow. | ||
| 715 | s.cursorSurface = nil | 762 | s.cursorSurface = nil | ||
| 716 | s.setCursor(ports.CursorChange{}) | 763 | s.setCursor(ports.CursorChange{}) | ||
| 717 | - | if old := s.windows[s.pointerFocus]; old != nil { | 764 | + | if surface, pointers, _, _ := s.pointerSurface(s.pointerFocus, 0, 0); surface != nil { |
| 718 | - | if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | 765 | + | for _, p := range pointers { |
| 719 | - | for _, p := range s.windowPointers(old) { | 766 | + | s.serial++ |
| 720 | - | s.serial++ | 767 | + | p.SendLeave(s.serial, surface) |
| 721 | - | p.SendLeave(s.serial, surface) | 768 | + | pointerFrame(p) |
| 722 | - | pointerFrame(p) | |||
| 723 | - | } | |||
| 724 | } | 769 | } | ||
| 725 | } | 770 | } | ||
| 726 | s.pointerFocus = 0 | 771 | s.pointerFocus = 0 | ||
| 727 | - | if w := s.windows[id]; w != nil { | 772 | + | if surface, pointers, sx, sy := s.pointerSurface(id, x, y); surface != nil { |
| 728 | - | if surface := w.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | 773 | + | s.pointerFocus, s.pointerX, s.pointerY = id, x, y |
| 729 | - | s.pointerFocus, s.pointerX, s.pointerY = id, x, y | 774 | + | for _, p := range pointers { |
| 730 | - | x, y := w.surfacePoint(x, y) | 775 | + | s.serial++ |
| 731 | - | for _, p := range s.windowPointers(w) { | 776 | + | p.SendEnter(s.serial, surface, server.FixedFromFloat(sx), server.FixedFromFloat(sy)) |
| 732 | - | s.serial++ | 777 | + | pointerFrame(p) |
| 733 | - | p.SendEnter(s.serial, surface, server.FixedFromFloat(x), server.FixedFromFloat(y)) | |||
| 734 | - | pointerFrame(p) | |||
| 735 | - | } | |||
| 736 | } | 778 | } | ||
| 737 | } | 779 | } | ||
| 738 | s.updateConstraint() | 780 | s.updateConstraint() | ||
M internal/app/app.go +8 -1
| @@ -34,7 +34,10 @@ type Options struct { | |||
| 34 | 34 | Timeout time.Duration | |
| 35 | 35 | NoTerminal bool | |
| 36 | 36 | // NoXwayland skips the X11 display even when the config enables it. | |
| 37 | - | NoXwayland bool | |
| 37 | + | NoXwayland bool | |
| 38 | + | // Session exports the displays to D-Bus and systemd user services | |
| 39 | + | // before any client starts, and withdraws them on exit. | |
| 40 | + | Session bool | |
| 38 | 41 | ScreenshotDir string | |
| 39 | 42 | // Sizes are the headless outputs (width, height), left to right; empty | |
| 40 | 43 | // means one 1920x1080 output. With several outputs, screenshots go to | |
| @@ -116,6 +119,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 116 | 119 | defer xdisplay.Close() | |
| 117 | 120 | childEnv = append(childEnv, "DISPLAY="+xdisplay.Name()) | |
| 118 | 121 | } | |
| 122 | + | if opts.Session { | |
| 123 | + | launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher")) | |
| 124 | + | defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher")) | |
| 125 | + | } | |
| 119 | 126 | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | |
| 120 | 127 | if inject != nil { | |
| 121 | 128 | inject(input) | |
| @@ -34,7 +34,10 @@ type Options struct { | |||||
| 34 | Timeout time.Duration | 34 | Timeout time.Duration | ||
| 35 | NoTerminal bool | 35 | NoTerminal bool | ||
| 36 | // NoXwayland skips the X11 display even when the config enables it. | 36 | // NoXwayland skips the X11 display even when the config enables it. | ||
| 37 | - | NoXwayland bool | 37 | + | NoXwayland bool |
| 38 | + | // Session exports the displays to D-Bus and systemd user services | |||
| 39 | + | // before any client starts, and withdraws them on exit. | |||
| 40 | + | Session bool | |||
| 38 | ScreenshotDir string | 41 | ScreenshotDir string | ||
| 39 | // Sizes are the headless outputs (width, height), left to right; empty | 42 | // Sizes are the headless outputs (width, height), left to right; empty | ||
| 40 | // means one 1920x1080 output. With several outputs, screenshots go to | 43 | // means one 1920x1080 output. With several outputs, screenshots go to | ||
| @@ -116,6 +119,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 116 | defer xdisplay.Close() | 119 | defer xdisplay.Close() | ||
| 117 | childEnv = append(childEnv, "DISPLAY="+xdisplay.Name()) | 120 | childEnv = append(childEnv, "DISPLAY="+xdisplay.Name()) | ||
| 118 | } | 121 | } | ||
| 122 | + | if opts.Session { | |||
| 123 | + | launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher")) | |||
| 124 | + | defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher")) | |||
| 125 | + | } | |||
| 119 | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | 126 | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | ||
| 120 | if inject != nil { | 127 | if inject != nil { | ||
| 121 | inject(input) | 128 | inject(input) | ||
M internal/core/core.go +61 -16
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "maps" | |
| 8 | 9 | "slices" | |
| 9 | 10 | "strconv" | |
| 10 | 11 | "strings" | |
| @@ -81,6 +82,13 @@ type Core struct { | |||
| 81 | 82 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | |
| 82 | 83 | popups map[WindowID]*popupState | |
| 83 | 84 | popupOrder []WindowID | |
| 85 | + | // layerFocus is the on-demand layer surface the user clicked; it keeps | |
| 86 | + | // the keyboard while it stays mapped on-demand and no window focus | |
| 87 | + | // changes on any output (layerOver, taken at the click): any click | |
| 88 | + | // elsewhere, bind, activation or new window takes the keyboard back. The | |
| 89 | + | // pointer moving to another output does not. | |
| 90 | + | layerFocus WindowID | |
| 91 | + | layerOver map[*screen]WindowID | |
| 84 | 92 | } | |
| 85 | 93 | ||
| 86 | 94 | func keyName(s string) string { | |
| @@ -289,22 +297,26 @@ func (c *Core) clientRect(p Placement) Rect { | |||
| 289 | 297 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | |
| 290 | 298 | } | |
| 291 | 299 | ||
| 292 | - | // visible reports whether the window is on screen on any output. | |
| 300 | + | // visible reports whether the window, layer surface or popup is on screen | |
| 301 | + | // on any output. | |
| 293 | 302 | func (c *Core) visible(id WindowID) bool { | |
| 303 | + | _, _, ok := c.windowRect(id) | |
| 304 | + | return ok | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // windowFocus is the focused window of each output. | |
| 308 | + | func (c *Core) windowFocus() map[*screen]WindowID { | |
| 309 | + | m := make(map[*screen]WindowID, len(c.screens)) | |
| 294 | 310 | for _, sc := range c.screens { | |
| 295 | - | for _, p := range sc.mon.Layout() { | |
| 296 | - | if p.ID == id { | |
| 297 | - | return !p.Hidden | |
| 298 | - | } | |
| 299 | - | } | |
| 311 | + | m[sc], _ = sc.mon.Focused() | |
| 300 | 312 | } | |
| 301 | - | return false | |
| 313 | + | return m | |
| 302 | 314 | } | |
| 303 | 315 | ||
| 304 | 316 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | |
| 305 | - | // interactivity and the highest ID on any output, else the focused window of | |
| 306 | - | // the focused output. On-demand layers never take focus automatically. When | |
| 307 | - | // the layer unmaps, focus returns to the window. | |
| 317 | + | // interactivity and the highest ID on any output, else a grabbing popup, | |
| 318 | + | // else a clicked on-demand layer (see layerFocus), else the focused window | |
| 319 | + | // of the focused output. | |
| 308 | 320 | func (c *Core) keyboardFocus() WindowID { | |
| 309 | 321 | var layer WindowID | |
| 310 | 322 | for _, sc := range c.screens { | |
| @@ -321,6 +333,12 @@ func (c *Core) keyboardFocus() WindowID { | |||
| 321 | 333 | if g := c.grabFocus(); g != 0 { | |
| 322 | 334 | return g | |
| 323 | 335 | } | |
| 336 | + | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { | |
| 337 | + | c.layerFocus, c.layerOver = 0, nil | |
| 338 | + | } | |
| 339 | + | if c.layerFocus != 0 { | |
| 340 | + | return c.layerFocus | |
| 341 | + | } | |
| 324 | 342 | id, _ := c.cur().mon.Focused() | |
| 325 | 343 | return id | |
| 326 | 344 | } | |
| @@ -503,9 +521,10 @@ func (c *Core) allLayers() []ports.LayerSurface { | |||
| 503 | 521 | return all | |
| 504 | 522 | } | |
| 505 | 523 | ||
| 506 | - | // hit returns the window under the global logical point and the point in | |
| 507 | - | // its surface coordinates. Fullscreen wins; otherwise the last visible | |
| 508 | - | // placement is topmost. | |
| 524 | + | // hit returns the surface under the global logical point and the point in | |
| 525 | + | // its surface coordinates: popups, then overlay and top layers, then | |
| 526 | + | // windows (fullscreen wins; otherwise the last visible placement is | |
| 527 | + | // topmost), then bottom and background layers. | |
| 509 | 528 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |
| 510 | 529 | o, ok := c.layout().At(x, y) | |
| 511 | 530 | if !ok { | |
| @@ -513,8 +532,15 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 513 | 532 | } | |
| 514 | 533 | lx, ly := x-float64(o.X), y-float64(o.Y) | |
| 515 | 534 | sc := c.screens[c.screenIndex(o.Info.Name)] | |
| 516 | - | // Popups are above everything but fullscreen covers nothing of them. | |
| 517 | - | if id, px, py := c.popupAt(sc, lx, ly); id != 0 { | |
| 535 | + | // Layer popups are over everything; window popups are over the windows | |
| 536 | + | // only, under the top and overlay layers (as drawn). | |
| 537 | + | if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 { | |
| 538 | + | return id, px, py | |
| 539 | + | } | |
| 540 | + | if id, px, py := layerAt(sc, lx, ly, true); id != 0 { | |
| 541 | + | return id, px, py | |
| 542 | + | } | |
| 543 | + | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | |
| 518 | 544 | return id, px, py | |
| 519 | 545 | } | |
| 520 | 546 | var id WindowID | |
| @@ -534,6 +560,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 534 | 560 | full = full || p.Fullscreen | |
| 535 | 561 | } | |
| 536 | 562 | } | |
| 563 | + | if id == 0 { | |
| 564 | + | return layerAt(sc, lx, ly, false) | |
| 565 | + | } | |
| 537 | 566 | return id, sx, sy | |
| 538 | 567 | } | |
| 539 | 568 | ||
| @@ -726,8 +755,23 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 726 | 755 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |
| 727 | 756 | return nil | |
| 728 | 757 | } | |
| 758 | + | // A click on an on-demand layer gives it the keyboard. | |
| 759 | + | if v.Pressed && c.onDemand(id) && c.layerFocus != id { | |
| 760 | + | c.layerFocus = id | |
| 761 | + | c.layerOver = c.windowFocus() | |
| 762 | + | if err := c.publish(ctx); err != nil { | |
| 763 | + | return nil | |
| 764 | + | } | |
| 765 | + | } else if v.Pressed && c.layerFocus != 0 && id != c.layerFocus && c.popupRoot(id) != c.layerFocus { | |
| 766 | + | // A click anywhere else takes the keyboard back. | |
| 767 | + | c.layerFocus = 0 | |
| 768 | + | if err := c.publish(ctx); err != nil { | |
| 769 | + | return nil | |
| 770 | + | } | |
| 771 | + | } | |
| 729 | 772 | // A click focuses the window and its output. | |
| 730 | - | if s, w := c.screenOf(id); v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) { | |
| 773 | + | s, w := c.screenOf(id) | |
| 774 | + | if v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) { | |
| 731 | 775 | w.FocusID(id) | |
| 732 | 776 | c.focusScreen = c.screenIndex(s.name()) | |
| 733 | 777 | if err := c.publish(ctx); err != nil { | |
| @@ -792,6 +836,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 792 | 836 | continue | |
| 793 | 837 | } | |
| 794 | 838 | before := c.cur().mon.Current() | |
| 839 | + | c.layerFocus = 0 // a bind acts on the windows | |
| 795 | 840 | effect := c.applyAction(action) | |
| 796 | 841 | if effect.Quit { | |
| 797 | 842 | return ErrQuit | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "github.com/bnema/nefertty/internal/ports" | 7 | "github.com/bnema/nefertty/internal/ports" | ||
| 8 | + | "maps" | |||
| 8 | "slices" | 9 | "slices" | ||
| 9 | "strconv" | 10 | "strconv" | ||
| 10 | "strings" | 11 | "strings" | ||
| @@ -81,6 +82,13 @@ type Core struct { | |||||
| 81 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | 82 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | ||
| 82 | popups map[WindowID]*popupState | 83 | popups map[WindowID]*popupState | ||
| 83 | popupOrder []WindowID | 84 | popupOrder []WindowID | ||
| 85 | + | // layerFocus is the on-demand layer surface the user clicked; it keeps | |||
| 86 | + | // the keyboard while it stays mapped on-demand and no window focus | |||
| 87 | + | // changes on any output (layerOver, taken at the click): any click | |||
| 88 | + | // elsewhere, bind, activation or new window takes the keyboard back. The | |||
| 89 | + | // pointer moving to another output does not. | |||
| 90 | + | layerFocus WindowID | |||
| 91 | + | layerOver map[*screen]WindowID | |||
| 84 | } | 92 | } | ||
| 85 | 93 | ||||
| 86 | func keyName(s string) string { | 94 | func keyName(s string) string { | ||
| @@ -289,22 +297,26 @@ func (c *Core) clientRect(p Placement) Rect { | |||||
| 289 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | 297 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | ||
| 290 | } | 298 | } | ||
| 291 | 299 | ||||
| 292 | - | // visible reports whether the window is on screen on any output. | 300 | + | // visible reports whether the window, layer surface or popup is on screen |
| 301 | + | // on any output. | |||
| 293 | func (c *Core) visible(id WindowID) bool { | 302 | func (c *Core) visible(id WindowID) bool { | ||
| 303 | + | _, _, ok := c.windowRect(id) | |||
| 304 | + | return ok | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // windowFocus is the focused window of each output. | |||
| 308 | + | func (c *Core) windowFocus() map[*screen]WindowID { | |||
| 309 | + | m := make(map[*screen]WindowID, len(c.screens)) | |||
| 294 | for _, sc := range c.screens { | 310 | for _, sc := range c.screens { | ||
| 295 | - | for _, p := range sc.mon.Layout() { | 311 | + | m[sc], _ = sc.mon.Focused() |
| 296 | - | if p.ID == id { | |||
| 297 | - | return !p.Hidden | |||
| 298 | - | } | |||
| 299 | - | } | |||
| 300 | } | 312 | } | ||
| 301 | - | return false | 313 | + | return m |
| 302 | } | 314 | } | ||
| 303 | 315 | ||||
| 304 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | 316 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | ||
| 305 | - | // interactivity and the highest ID on any output, else the focused window of | 317 | + | // interactivity and the highest ID on any output, else a grabbing popup, |
| 306 | - | // the focused output. On-demand layers never take focus automatically. When | 318 | + | // else a clicked on-demand layer (see layerFocus), else the focused window |
| 307 | - | // the layer unmaps, focus returns to the window. | 319 | + | // of the focused output. |
| 308 | func (c *Core) keyboardFocus() WindowID { | 320 | func (c *Core) keyboardFocus() WindowID { | ||
| 309 | var layer WindowID | 321 | var layer WindowID | ||
| 310 | for _, sc := range c.screens { | 322 | for _, sc := range c.screens { | ||
| @@ -321,6 +333,12 @@ func (c *Core) keyboardFocus() WindowID { | |||||
| 321 | if g := c.grabFocus(); g != 0 { | 333 | if g := c.grabFocus(); g != 0 { | ||
| 322 | return g | 334 | return g | ||
| 323 | } | 335 | } | ||
| 336 | + | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { | |||
| 337 | + | c.layerFocus, c.layerOver = 0, nil | |||
| 338 | + | } | |||
| 339 | + | if c.layerFocus != 0 { | |||
| 340 | + | return c.layerFocus | |||
| 341 | + | } | |||
| 324 | id, _ := c.cur().mon.Focused() | 342 | id, _ := c.cur().mon.Focused() | ||
| 325 | return id | 343 | return id | ||
| 326 | } | 344 | } | ||
| @@ -503,9 +521,10 @@ func (c *Core) allLayers() []ports.LayerSurface { | |||||
| 503 | return all | 521 | return all | ||
| 504 | } | 522 | } | ||
| 505 | 523 | ||||
| 506 | - | // hit returns the window under the global logical point and the point in | 524 | + | // hit returns the surface under the global logical point and the point in |
| 507 | - | // its surface coordinates. Fullscreen wins; otherwise the last visible | 525 | + | // its surface coordinates: popups, then overlay and top layers, then |
| 508 | - | // placement is topmost. | 526 | + | // windows (fullscreen wins; otherwise the last visible placement is |
| 527 | + | // topmost), then bottom and background layers. | |||
| 509 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | 528 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | ||
| 510 | o, ok := c.layout().At(x, y) | 529 | o, ok := c.layout().At(x, y) | ||
| 511 | if !ok { | 530 | if !ok { | ||
| @@ -513,8 +532,15 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 513 | } | 532 | } | ||
| 514 | lx, ly := x-float64(o.X), y-float64(o.Y) | 533 | lx, ly := x-float64(o.X), y-float64(o.Y) | ||
| 515 | sc := c.screens[c.screenIndex(o.Info.Name)] | 534 | sc := c.screens[c.screenIndex(o.Info.Name)] | ||
| 516 | - | // Popups are above everything but fullscreen covers nothing of them. | 535 | + | // Layer popups are over everything; window popups are over the windows |
| 517 | - | if id, px, py := c.popupAt(sc, lx, ly); id != 0 { | 536 | + | // only, under the top and overlay layers (as drawn). |
| 537 | + | if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 { | |||
| 538 | + | return id, px, py | |||
| 539 | + | } | |||
| 540 | + | if id, px, py := layerAt(sc, lx, ly, true); id != 0 { | |||
| 541 | + | return id, px, py | |||
| 542 | + | } | |||
| 543 | + | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | |||
| 518 | return id, px, py | 544 | return id, px, py | ||
| 519 | } | 545 | } | ||
| 520 | var id WindowID | 546 | var id WindowID | ||
| @@ -534,6 +560,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 534 | full = full || p.Fullscreen | 560 | full = full || p.Fullscreen | ||
| 535 | } | 561 | } | ||
| 536 | } | 562 | } | ||
| 563 | + | if id == 0 { | |||
| 564 | + | return layerAt(sc, lx, ly, false) | |||
| 565 | + | } | |||
| 537 | return id, sx, sy | 566 | return id, sx, sy | ||
| 538 | } | 567 | } | ||
| 539 | 568 | ||||
| @@ -726,8 +755,23 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 726 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | 755 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | ||
| 727 | return nil | 756 | return nil | ||
| 728 | } | 757 | } | ||
| 758 | + | // A click on an on-demand layer gives it the keyboard. | |||
| 759 | + | if v.Pressed && c.onDemand(id) && c.layerFocus != id { | |||
| 760 | + | c.layerFocus = id | |||
| 761 | + | c.layerOver = c.windowFocus() | |||
| 762 | + | if err := c.publish(ctx); err != nil { | |||
| 763 | + | return nil | |||
| 764 | + | } | |||
| 765 | + | } else if v.Pressed && c.layerFocus != 0 && id != c.layerFocus && c.popupRoot(id) != c.layerFocus { | |||
| 766 | + | // A click anywhere else takes the keyboard back. | |||
| 767 | + | c.layerFocus = 0 | |||
| 768 | + | if err := c.publish(ctx); err != nil { | |||
| 769 | + | return nil | |||
| 770 | + | } | |||
| 771 | + | } | |||
| 729 | // A click focuses the window and its output. | 772 | // A click focuses the window and its output. | ||
| 730 | - | if s, w := c.screenOf(id); v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) { | 773 | + | s, w := c.screenOf(id) |
| 774 | + | if v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) { | |||
| 731 | w.FocusID(id) | 775 | w.FocusID(id) | ||
| 732 | c.focusScreen = c.screenIndex(s.name()) | 776 | c.focusScreen = c.screenIndex(s.name()) | ||
| 733 | if err := c.publish(ctx); err != nil { | 777 | if err := c.publish(ctx); err != nil { | ||
| @@ -792,6 +836,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 792 | continue | 836 | continue | ||
| 793 | } | 837 | } | ||
| 794 | before := c.cur().mon.Current() | 838 | before := c.cur().mon.Current() | ||
| 839 | + | c.layerFocus = 0 // a bind acts on the windows | |||
| 795 | effect := c.applyAction(action) | 840 | effect := c.applyAction(action) | ||
| 796 | if effect.Quit { | 841 | if effect.Quit { | ||
| 797 | return ErrQuit | 842 | return ErrQuit | ||
A internal/core/layer_input_test.go +271 -0
| @@ -0,0 +1,271 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 8 | + | "github.com/bnema/nefertty/internal/core" | |
| 9 | + | "github.com/bnema/nefertty/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // next returns the next command of type T matching ok, skipping others. | |
| 13 | + | func next[T ports.ClientCommand](t *testing.T, ch <-chan ports.ClientCommand, ok func(T) bool) T { | |
| 14 | + | t.Helper() | |
| 15 | + | for { | |
| 16 | + | if v, is := command(t, ch).(T); is && ok(v) { | |
| 17 | + | return v | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func anyOf[T any](T) bool { return true } | |
| 23 | + | ||
| 24 | + | // Bars and launchers get the pointer, popups and, on click, the keyboard. | |
| 25 | + | func TestLayerPointerPopupAndFocus(t *testing.T) { | |
| 26 | + | cfg := config.Defaults() | |
| 27 | + | cfg.Border.Width = 0 | |
| 28 | + | client := make(chan ports.ClientEvent, 8) | |
| 29 | + | input := make(chan ports.InputEvent, 8) | |
| 30 | + | output := make(chan ports.OutputEvent, 8) | |
| 31 | + | commands := make(chan ports.ClientCommand, 256) | |
| 32 | + | scenes := make(chan []ports.Scene, 1) | |
| 33 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 38 | + | defer cancel() | |
| 39 | + | go c.Run(ctx) | |
| 40 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 41 | + | scene(t, scenes) | |
| 42 | + | client <- ports.WindowMapped{ID: 1} | |
| 43 | + | scene(t, scenes) | |
| 44 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 { | |
| 45 | + | t.Fatalf("focus %d", f.ID) | |
| 46 | + | } | |
| 47 | + | bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 100, Height: 10, ExclusiveZone: 10, Keyboard: 2} | |
| 48 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |
| 49 | + | scene(t, scenes) | |
| 50 | + | ||
| 51 | + | // The bar gets the pointer, in its own coordinates. | |
| 52 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |
| 53 | + | if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 || v.X != 50 || v.Y != 5 { | |
| 54 | + | t.Fatalf("pointer focus %+v, want the bar", v) | |
| 55 | + | } | |
| 56 | + | // A click gives the on-demand bar the keyboard. | |
| 57 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 58 | + | if v := next(t, commands, anyOf[ports.PointerButtonTo]); v.ID != 6 { | |
| 59 | + | t.Fatalf("button to %d", v.ID) | |
| 60 | + | } | |
| 61 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 { | |
| 62 | + | t.Fatalf("focus %d, want the bar", f.ID) | |
| 63 | + | } | |
| 64 | + | input <- ports.PointerButton{Button: 0x110} | |
| 65 | + | ||
| 66 | + | // A menu from the bar is placed under it and slides inside the output. | |
| 67 | + | pos := ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 90, Y: 0, W: 10, H: 10}, Anchor: ports.EdgeBottomLeft, Gravity: ports.EdgeBottomRight, Adjust: ports.AdjustSlideX} | |
| 68 | + | client <- ports.PopupRequest{ID: 9, Parent: 6, Positioner: pos, Grab: true} | |
| 69 | + | cp := next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 }) | |
| 70 | + | if cp.Rect != (ports.Rect{X: 70, Y: 10, W: 30, H: 20}) { | |
| 71 | + | t.Fatalf("popup rect %+v", cp.Rect) | |
| 72 | + | } | |
| 73 | + | client <- ports.PopupMapped{ID: 9} | |
| 74 | + | sceneMatch(t, scenes, func(sc ports.Scene) bool { | |
| 75 | + | w := sc.Windows[len(sc.Windows)-1] | |
| 76 | + | return w.Popup && w.ID == 9 && w.Rect == (ports.Rect{X: 70, Y: 10, W: 30, H: 20}) | |
| 77 | + | }) | |
| 78 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 9 { | |
| 79 | + | t.Fatalf("grab focus %d", f.ID) | |
| 80 | + | } | |
| 81 | + | input <- ports.PointerMotion{X: 80, Y: 15} | |
| 82 | + | if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 9 || v.X != 10 || v.Y != 5 { | |
| 83 | + | t.Fatalf("pointer focus %+v, want the menu", v) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // A click on the window closes the menu and takes the keyboard back. | |
| 87 | + | input <- ports.PointerMotion{X: 20, Y: 50} | |
| 88 | + | if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 { | |
| 89 | + | t.Fatalf("pointer focus %+v, want the window", v) | |
| 90 | + | } | |
| 91 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 92 | + | if v := next(t, commands, anyOf[ports.ClosePopup]); v.ID != 9 { | |
| 93 | + | t.Fatalf("closed %d", v.ID) | |
| 94 | + | } | |
| 95 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 { | |
| 96 | + | t.Fatalf("focus %d, want the window", f.ID) | |
| 97 | + | } | |
| 98 | + | input <- ports.PointerButton{Button: 0x110} | |
| 99 | + | ||
| 100 | + | // An exclusive launcher wins over a clicked on-demand bar. | |
| 101 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |
| 102 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 }) | |
| 103 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 104 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 }) | |
| 105 | + | input <- ports.PointerButton{Button: 0x110} | |
| 106 | + | launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 20, Height: 20, Keyboard: 1} | |
| 107 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}} | |
| 108 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 7 { | |
| 109 | + | t.Fatalf("focus %d, want the launcher", f.ID) | |
| 110 | + | } | |
| 111 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |
| 112 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 { | |
| 113 | + | t.Fatalf("focus %d, want the bar back", f.ID) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Unmapped then mapped again, the bar does not take the keyboard back. | |
| 117 | + | client <- ports.LayerChanged{} | |
| 118 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 1 }) | |
| 119 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |
| 120 | + | scene(t, scenes) | |
| 121 | + | input <- ports.PointerMotion{X: 20, Y: 50} // publishes | |
| 122 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 1 }) | |
| 123 | + | select { | |
| 124 | + | case v := <-commands: | |
| 125 | + | if f, ok := v.(ports.FocusWindow); ok { | |
| 126 | + | t.Fatalf("focus moved to %d", f.ID) | |
| 127 | + | } | |
| 128 | + | default: | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // A window focus change by keyboard or a new window takes it back too. | |
| 132 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |
| 133 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 }) | |
| 134 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 135 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 }) | |
| 136 | + | input <- ports.PointerButton{Button: 0x110} | |
| 137 | + | client <- ports.WindowMapped{ID: 2} | |
| 138 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 2 { | |
| 139 | + | t.Fatalf("focus %d, want the new window", f.ID) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // A window's menu stays with the windows: an overlay layer covers it. | |
| 144 | + | func TestWindowPopupUnderOverlay(t *testing.T) { | |
| 145 | + | cfg := config.Defaults() | |
| 146 | + | cfg.Border.Width = 0 | |
| 147 | + | client := make(chan ports.ClientEvent, 8) | |
| 148 | + | input := make(chan ports.InputEvent, 8) | |
| 149 | + | output := make(chan ports.OutputEvent, 8) | |
| 150 | + | commands := make(chan ports.ClientCommand, 256) | |
| 151 | + | scenes := make(chan []ports.Scene, 1) | |
| 152 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 157 | + | defer cancel() | |
| 158 | + | go c.Run(ctx) | |
| 159 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 160 | + | scene(t, scenes) | |
| 161 | + | client <- ports.WindowMapped{ID: 1} | |
| 162 | + | scene(t, scenes) | |
| 163 | + | client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 0, Y: 0, W: 10, H: 10}}} | |
| 164 | + | next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 }) | |
| 165 | + | client <- ports.PopupMapped{ID: 9} | |
| 166 | + | sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 }) | |
| 167 | + | if w := sc.Windows[1]; !w.Popup || w.OverLayers { | |
| 168 | + | t.Fatalf("window popup %+v", w) | |
| 169 | + | } | |
| 170 | + | osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: 15} | |
| 171 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{osd}} | |
| 172 | + | scene(t, scenes) | |
| 173 | + | input <- ports.PointerMotion{X: 5, Y: 5} | |
| 174 | + | if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 7 { | |
| 175 | + | t.Fatalf("hit %d, want the overlay", v.ID) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // A fullscreen window hides top and bottom layers from the pointer, not | |
| 180 | + | // overlay; background and bottom layers get it only where no window is. | |
| 181 | + | func TestLayerHitOrder(t *testing.T) { | |
| 182 | + | cfg := config.Defaults() | |
| 183 | + | cfg.Border.Width = 0 | |
| 184 | + | client := make(chan ports.ClientEvent, 8) | |
| 185 | + | input := make(chan ports.InputEvent, 8) | |
| 186 | + | output := make(chan ports.OutputEvent, 8) | |
| 187 | + | commands := make(chan ports.ClientCommand, 256) | |
| 188 | + | scenes := make(chan []ports.Scene, 1) | |
| 189 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 190 | + | if err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 194 | + | defer cancel() | |
| 195 | + | go c.Run(ctx) | |
| 196 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 197 | + | scene(t, scenes) | |
| 198 | + | wallpaper := ports.LayerSurface{ID: 5, Layer: ports.LayerBackground, Anchor: 15} | |
| 199 | + | bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10} | |
| 200 | + | osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: ports.AnchorBottom, Width: 20, Height: 10} | |
| 201 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{wallpaper, bar, osd}} | |
| 202 | + | scene(t, scenes) | |
| 203 | + | hit := func(x, y float64, want ports.WindowID) { | |
| 204 | + | t.Helper() | |
| 205 | + | input <- ports.PointerMotion{X: x, Y: y} | |
| 206 | + | // A focus change is sent only when the target changes; motion always. | |
| 207 | + | if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != want { | |
| 208 | + | t.Fatalf("(%v,%v) hit %d, want %d", x, y, v.ID, want) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | hit(50, 40, 5) | |
| 212 | + | hit(50, 5, 6) | |
| 213 | + | client <- ports.WindowMapped{ID: 1} | |
| 214 | + | scene(t, scenes) | |
| 215 | + | hit(50, 40, 1) | |
| 216 | + | hit(50, 5, 6) | |
| 217 | + | hit(50, 75, 7) | |
| 218 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |
| 219 | + | scene(t, scenes) | |
| 220 | + | hit(50, 5, 1) | |
| 221 | + | hit(50, 75, 7) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // The pointer moving to another output does not take the keyboard from a | |
| 225 | + | // clicked on-demand layer: keys still reach it. | |
| 226 | + | func TestLayerFocusAcrossOutputs(t *testing.T) { | |
| 227 | + | cfg := config.Defaults() | |
| 228 | + | cfg.Border.Width = 0 | |
| 229 | + | client := make(chan ports.ClientEvent, 8) | |
| 230 | + | input := make(chan ports.InputEvent, 8) | |
| 231 | + | output := make(chan ports.OutputEvent, 8) | |
| 232 | + | commands := make(chan ports.ClientCommand, 256) | |
| 233 | + | scenes := make(chan []ports.Scene, 1) | |
| 234 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 235 | + | if err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 239 | + | defer cancel() | |
| 240 | + | go c.Run(ctx) | |
| 241 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 242 | + | scene(t, scenes) | |
| 243 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}} | |
| 244 | + | scene(t, scenes) | |
| 245 | + | client <- ports.WindowMapped{ID: 1} | |
| 246 | + | scene(t, scenes) | |
| 247 | + | bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, Keyboard: 2, Output: "OUT-1"} | |
| 248 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |
| 249 | + | scene(t, scenes) | |
| 250 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |
| 251 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 }) | |
| 252 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 253 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 }) | |
| 254 | + | input <- ports.PointerButton{Button: 0x110} | |
| 255 | + | input <- ports.PointerMotion{X: 100, Y: 40} // onto OUT-2, now focused | |
| 256 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 0 }) | |
| 257 | + | input <- ports.KeyEvent{Keysym: "a", Pressed: true} | |
| 258 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 { | |
| 259 | + | t.Fatalf("key to %d, want the bar", v.ID) | |
| 260 | + | } | |
| 261 | + | input <- ports.KeyEvent{Keysym: "a"} | |
| 262 | + | next(t, commands, anyOf[ports.ForwardKey]) | |
| 263 | + | ||
| 264 | + | // A window mapped on OUT-2 (focused there) takes the keyboard back. | |
| 265 | + | client <- ports.WindowMapped{ID: 2} | |
| 266 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 2 }) | |
| 267 | + | input <- ports.KeyEvent{Keysym: "b", Pressed: true} | |
| 268 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 2 { | |
| 269 | + | t.Fatalf("key to %d, want the new window", v.ID) | |
| 270 | + | } | |
| 271 | + | } | |
| @@ -0,0 +1,271 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefertty/internal/adapters/config" | |||
| 8 | + | "github.com/bnema/nefertty/internal/core" | |||
| 9 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // next returns the next command of type T matching ok, skipping others. | |||
| 13 | + | func next[T ports.ClientCommand](t *testing.T, ch <-chan ports.ClientCommand, ok func(T) bool) T { | |||
| 14 | + | t.Helper() | |||
| 15 | + | for { | |||
| 16 | + | if v, is := command(t, ch).(T); is && ok(v) { | |||
| 17 | + | return v | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func anyOf[T any](T) bool { return true } | |||
| 23 | + | ||||
| 24 | + | // Bars and launchers get the pointer, popups and, on click, the keyboard. | |||
| 25 | + | func TestLayerPointerPopupAndFocus(t *testing.T) { | |||
| 26 | + | cfg := config.Defaults() | |||
| 27 | + | cfg.Border.Width = 0 | |||
| 28 | + | client := make(chan ports.ClientEvent, 8) | |||
| 29 | + | input := make(chan ports.InputEvent, 8) | |||
| 30 | + | output := make(chan ports.OutputEvent, 8) | |||
| 31 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 32 | + | scenes := make(chan []ports.Scene, 1) | |||
| 33 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 38 | + | defer cancel() | |||
| 39 | + | go c.Run(ctx) | |||
| 40 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 41 | + | scene(t, scenes) | |||
| 42 | + | client <- ports.WindowMapped{ID: 1} | |||
| 43 | + | scene(t, scenes) | |||
| 44 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 { | |||
| 45 | + | t.Fatalf("focus %d", f.ID) | |||
| 46 | + | } | |||
| 47 | + | bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 100, Height: 10, ExclusiveZone: 10, Keyboard: 2} | |||
| 48 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |||
| 49 | + | scene(t, scenes) | |||
| 50 | + | ||||
| 51 | + | // The bar gets the pointer, in its own coordinates. | |||
| 52 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |||
| 53 | + | if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 || v.X != 50 || v.Y != 5 { | |||
| 54 | + | t.Fatalf("pointer focus %+v, want the bar", v) | |||
| 55 | + | } | |||
| 56 | + | // A click gives the on-demand bar the keyboard. | |||
| 57 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 58 | + | if v := next(t, commands, anyOf[ports.PointerButtonTo]); v.ID != 6 { | |||
| 59 | + | t.Fatalf("button to %d", v.ID) | |||
| 60 | + | } | |||
| 61 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 { | |||
| 62 | + | t.Fatalf("focus %d, want the bar", f.ID) | |||
| 63 | + | } | |||
| 64 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 65 | + | ||||
| 66 | + | // A menu from the bar is placed under it and slides inside the output. | |||
| 67 | + | pos := ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 90, Y: 0, W: 10, H: 10}, Anchor: ports.EdgeBottomLeft, Gravity: ports.EdgeBottomRight, Adjust: ports.AdjustSlideX} | |||
| 68 | + | client <- ports.PopupRequest{ID: 9, Parent: 6, Positioner: pos, Grab: true} | |||
| 69 | + | cp := next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 }) | |||
| 70 | + | if cp.Rect != (ports.Rect{X: 70, Y: 10, W: 30, H: 20}) { | |||
| 71 | + | t.Fatalf("popup rect %+v", cp.Rect) | |||
| 72 | + | } | |||
| 73 | + | client <- ports.PopupMapped{ID: 9} | |||
| 74 | + | sceneMatch(t, scenes, func(sc ports.Scene) bool { | |||
| 75 | + | w := sc.Windows[len(sc.Windows)-1] | |||
| 76 | + | return w.Popup && w.ID == 9 && w.Rect == (ports.Rect{X: 70, Y: 10, W: 30, H: 20}) | |||
| 77 | + | }) | |||
| 78 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 9 { | |||
| 79 | + | t.Fatalf("grab focus %d", f.ID) | |||
| 80 | + | } | |||
| 81 | + | input <- ports.PointerMotion{X: 80, Y: 15} | |||
| 82 | + | if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 9 || v.X != 10 || v.Y != 5 { | |||
| 83 | + | t.Fatalf("pointer focus %+v, want the menu", v) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // A click on the window closes the menu and takes the keyboard back. | |||
| 87 | + | input <- ports.PointerMotion{X: 20, Y: 50} | |||
| 88 | + | if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 { | |||
| 89 | + | t.Fatalf("pointer focus %+v, want the window", v) | |||
| 90 | + | } | |||
| 91 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 92 | + | if v := next(t, commands, anyOf[ports.ClosePopup]); v.ID != 9 { | |||
| 93 | + | t.Fatalf("closed %d", v.ID) | |||
| 94 | + | } | |||
| 95 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 { | |||
| 96 | + | t.Fatalf("focus %d, want the window", f.ID) | |||
| 97 | + | } | |||
| 98 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 99 | + | ||||
| 100 | + | // An exclusive launcher wins over a clicked on-demand bar. | |||
| 101 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |||
| 102 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 }) | |||
| 103 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 104 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 }) | |||
| 105 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 106 | + | launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 20, Height: 20, Keyboard: 1} | |||
| 107 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}} | |||
| 108 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 7 { | |||
| 109 | + | t.Fatalf("focus %d, want the launcher", f.ID) | |||
| 110 | + | } | |||
| 111 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |||
| 112 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 { | |||
| 113 | + | t.Fatalf("focus %d, want the bar back", f.ID) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Unmapped then mapped again, the bar does not take the keyboard back. | |||
| 117 | + | client <- ports.LayerChanged{} | |||
| 118 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 1 }) | |||
| 119 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |||
| 120 | + | scene(t, scenes) | |||
| 121 | + | input <- ports.PointerMotion{X: 20, Y: 50} // publishes | |||
| 122 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 1 }) | |||
| 123 | + | select { | |||
| 124 | + | case v := <-commands: | |||
| 125 | + | if f, ok := v.(ports.FocusWindow); ok { | |||
| 126 | + | t.Fatalf("focus moved to %d", f.ID) | |||
| 127 | + | } | |||
| 128 | + | default: | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // A window focus change by keyboard or a new window takes it back too. | |||
| 132 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |||
| 133 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 }) | |||
| 134 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 135 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 }) | |||
| 136 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 137 | + | client <- ports.WindowMapped{ID: 2} | |||
| 138 | + | if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 2 { | |||
| 139 | + | t.Fatalf("focus %d, want the new window", f.ID) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // A window's menu stays with the windows: an overlay layer covers it. | |||
| 144 | + | func TestWindowPopupUnderOverlay(t *testing.T) { | |||
| 145 | + | cfg := config.Defaults() | |||
| 146 | + | cfg.Border.Width = 0 | |||
| 147 | + | client := make(chan ports.ClientEvent, 8) | |||
| 148 | + | input := make(chan ports.InputEvent, 8) | |||
| 149 | + | output := make(chan ports.OutputEvent, 8) | |||
| 150 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 151 | + | scenes := make(chan []ports.Scene, 1) | |||
| 152 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 153 | + | if err != nil { | |||
| 154 | + | t.Fatal(err) | |||
| 155 | + | } | |||
| 156 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 157 | + | defer cancel() | |||
| 158 | + | go c.Run(ctx) | |||
| 159 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 160 | + | scene(t, scenes) | |||
| 161 | + | client <- ports.WindowMapped{ID: 1} | |||
| 162 | + | scene(t, scenes) | |||
| 163 | + | client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 0, Y: 0, W: 10, H: 10}}} | |||
| 164 | + | next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 }) | |||
| 165 | + | client <- ports.PopupMapped{ID: 9} | |||
| 166 | + | sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 }) | |||
| 167 | + | if w := sc.Windows[1]; !w.Popup || w.OverLayers { | |||
| 168 | + | t.Fatalf("window popup %+v", w) | |||
| 169 | + | } | |||
| 170 | + | osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: 15} | |||
| 171 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{osd}} | |||
| 172 | + | scene(t, scenes) | |||
| 173 | + | input <- ports.PointerMotion{X: 5, Y: 5} | |||
| 174 | + | if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 7 { | |||
| 175 | + | t.Fatalf("hit %d, want the overlay", v.ID) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // A fullscreen window hides top and bottom layers from the pointer, not | |||
| 180 | + | // overlay; background and bottom layers get it only where no window is. | |||
| 181 | + | func TestLayerHitOrder(t *testing.T) { | |||
| 182 | + | cfg := config.Defaults() | |||
| 183 | + | cfg.Border.Width = 0 | |||
| 184 | + | client := make(chan ports.ClientEvent, 8) | |||
| 185 | + | input := make(chan ports.InputEvent, 8) | |||
| 186 | + | output := make(chan ports.OutputEvent, 8) | |||
| 187 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 188 | + | scenes := make(chan []ports.Scene, 1) | |||
| 189 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 190 | + | if err != nil { | |||
| 191 | + | t.Fatal(err) | |||
| 192 | + | } | |||
| 193 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 194 | + | defer cancel() | |||
| 195 | + | go c.Run(ctx) | |||
| 196 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 197 | + | scene(t, scenes) | |||
| 198 | + | wallpaper := ports.LayerSurface{ID: 5, Layer: ports.LayerBackground, Anchor: 15} | |||
| 199 | + | bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10} | |||
| 200 | + | osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: ports.AnchorBottom, Width: 20, Height: 10} | |||
| 201 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{wallpaper, bar, osd}} | |||
| 202 | + | scene(t, scenes) | |||
| 203 | + | hit := func(x, y float64, want ports.WindowID) { | |||
| 204 | + | t.Helper() | |||
| 205 | + | input <- ports.PointerMotion{X: x, Y: y} | |||
| 206 | + | // A focus change is sent only when the target changes; motion always. | |||
| 207 | + | if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != want { | |||
| 208 | + | t.Fatalf("(%v,%v) hit %d, want %d", x, y, v.ID, want) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | hit(50, 40, 5) | |||
| 212 | + | hit(50, 5, 6) | |||
| 213 | + | client <- ports.WindowMapped{ID: 1} | |||
| 214 | + | scene(t, scenes) | |||
| 215 | + | hit(50, 40, 1) | |||
| 216 | + | hit(50, 5, 6) | |||
| 217 | + | hit(50, 75, 7) | |||
| 218 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |||
| 219 | + | scene(t, scenes) | |||
| 220 | + | hit(50, 5, 1) | |||
| 221 | + | hit(50, 75, 7) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // The pointer moving to another output does not take the keyboard from a | |||
| 225 | + | // clicked on-demand layer: keys still reach it. | |||
| 226 | + | func TestLayerFocusAcrossOutputs(t *testing.T) { | |||
| 227 | + | cfg := config.Defaults() | |||
| 228 | + | cfg.Border.Width = 0 | |||
| 229 | + | client := make(chan ports.ClientEvent, 8) | |||
| 230 | + | input := make(chan ports.InputEvent, 8) | |||
| 231 | + | output := make(chan ports.OutputEvent, 8) | |||
| 232 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 233 | + | scenes := make(chan []ports.Scene, 1) | |||
| 234 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 235 | + | if err != nil { | |||
| 236 | + | t.Fatal(err) | |||
| 237 | + | } | |||
| 238 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 239 | + | defer cancel() | |||
| 240 | + | go c.Run(ctx) | |||
| 241 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 242 | + | scene(t, scenes) | |||
| 243 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}} | |||
| 244 | + | scene(t, scenes) | |||
| 245 | + | client <- ports.WindowMapped{ID: 1} | |||
| 246 | + | scene(t, scenes) | |||
| 247 | + | bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, Keyboard: 2, Output: "OUT-1"} | |||
| 248 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |||
| 249 | + | scene(t, scenes) | |||
| 250 | + | input <- ports.PointerMotion{X: 50, Y: 5} | |||
| 251 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 }) | |||
| 252 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 253 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 }) | |||
| 254 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 255 | + | input <- ports.PointerMotion{X: 100, Y: 40} // onto OUT-2, now focused | |||
| 256 | + | next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 0 }) | |||
| 257 | + | input <- ports.KeyEvent{Keysym: "a", Pressed: true} | |||
| 258 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 { | |||
| 259 | + | t.Fatalf("key to %d, want the bar", v.ID) | |||
| 260 | + | } | |||
| 261 | + | input <- ports.KeyEvent{Keysym: "a"} | |||
| 262 | + | next(t, commands, anyOf[ports.ForwardKey]) | |||
| 263 | + | ||||
| 264 | + | // A window mapped on OUT-2 (focused there) takes the keyboard back. | |||
| 265 | + | client <- ports.WindowMapped{ID: 2} | |||
| 266 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 2 }) | |||
| 267 | + | input <- ports.KeyEvent{Keysym: "b", Pressed: true} | |||
| 268 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 2 { | |||
| 269 | + | t.Fatalf("key to %d, want the new window", v.ID) | |||
| 270 | + | } | |||
| 271 | + | } | |||
M internal/core/layers.go +58 -0
| @@ -73,3 +73,61 @@ func arrangeLayers(outW, outH int, layers []ports.LayerSurface) (placed []ports. | |||
| 73 | 73 | sort.SliceStable(placed, func(i, j int) bool { return placed[i].Layer < placed[j].Layer }) | |
| 74 | 74 | return | |
| 75 | 75 | } | |
| 76 | + | ||
| 77 | + | // layerOf finds a placed layer surface by ID. | |
| 78 | + | func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |
| 79 | + | for _, sc := range c.screens { | |
| 80 | + | for _, l := range sc.placed { | |
| 81 | + | if l.ID == id { | |
| 82 | + | return sc, l, true | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | return nil, ports.SceneLayer{}, false | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // hasFullscreen reports whether a visible fullscreen window covers the screen. | |
| 90 | + | func hasFullscreen(sc *screen) bool { | |
| 91 | + | for _, p := range sc.mon.Layout() { | |
| 92 | + | if p.Fullscreen && !p.Hidden { | |
| 93 | + | return true | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // shown reports whether a layer is drawn: a fullscreen window hides the | |
| 100 | + | // bottom and top layers, as the renderer does. | |
| 101 | + | func shown(sc *screen, layer ports.Layer) bool { | |
| 102 | + | return !(hasFullscreen(sc) && (layer == ports.LayerBottom || layer == ports.LayerTop)) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // layerAt returns the topmost shown layer surface under the output-local | |
| 106 | + | // point among those above (or below) the windows. | |
| 107 | + | func layerAt(sc *screen, lx, ly float64, above bool) (WindowID, float64, float64) { | |
| 108 | + | full := hasFullscreen(sc) | |
| 109 | + | for i := len(sc.placed) - 1; i >= 0; i-- { | |
| 110 | + | l := sc.placed[i] | |
| 111 | + | r := l.Rect | |
| 112 | + | if (l.Layer >= ports.LayerTop) != above || (full && (l.Layer == ports.LayerBottom || l.Layer == ports.LayerTop)) || r.W <= 0 || r.H <= 0 { | |
| 113 | + | continue | |
| 114 | + | } | |
| 115 | + | if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | |
| 116 | + | return l.ID, lx - float64(r.X), ly - float64(r.Y) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return 0, 0, 0 | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // onDemand reports whether id is a mapped layer surface with on-demand | |
| 123 | + | // keyboard interactivity. | |
| 124 | + | func (c *Core) onDemand(id WindowID) bool { | |
| 125 | + | for _, sc := range c.screens { | |
| 126 | + | for _, l := range sc.layers { | |
| 127 | + | if l.ID == id { | |
| 128 | + | return l.Keyboard == 2 | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return false | |
| 133 | + | } | |
| @@ -73,3 +73,61 @@ func arrangeLayers(outW, outH int, layers []ports.LayerSurface) (placed []ports. | |||||
| 73 | sort.SliceStable(placed, func(i, j int) bool { return placed[i].Layer < placed[j].Layer }) | 73 | sort.SliceStable(placed, func(i, j int) bool { return placed[i].Layer < placed[j].Layer }) | ||
| 74 | return | 74 | return | ||
| 75 | } | 75 | } | ||
| 76 | + | ||||
| 77 | + | // layerOf finds a placed layer surface by ID. | |||
| 78 | + | func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |||
| 79 | + | for _, sc := range c.screens { | |||
| 80 | + | for _, l := range sc.placed { | |||
| 81 | + | if l.ID == id { | |||
| 82 | + | return sc, l, true | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | return nil, ports.SceneLayer{}, false | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // hasFullscreen reports whether a visible fullscreen window covers the screen. | |||
| 90 | + | func hasFullscreen(sc *screen) bool { | |||
| 91 | + | for _, p := range sc.mon.Layout() { | |||
| 92 | + | if p.Fullscreen && !p.Hidden { | |||
| 93 | + | return true | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // shown reports whether a layer is drawn: a fullscreen window hides the | |||
| 100 | + | // bottom and top layers, as the renderer does. | |||
| 101 | + | func shown(sc *screen, layer ports.Layer) bool { | |||
| 102 | + | return !(hasFullscreen(sc) && (layer == ports.LayerBottom || layer == ports.LayerTop)) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // layerAt returns the topmost shown layer surface under the output-local | |||
| 106 | + | // point among those above (or below) the windows. | |||
| 107 | + | func layerAt(sc *screen, lx, ly float64, above bool) (WindowID, float64, float64) { | |||
| 108 | + | full := hasFullscreen(sc) | |||
| 109 | + | for i := len(sc.placed) - 1; i >= 0; i-- { | |||
| 110 | + | l := sc.placed[i] | |||
| 111 | + | r := l.Rect | |||
| 112 | + | if (l.Layer >= ports.LayerTop) != above || (full && (l.Layer == ports.LayerBottom || l.Layer == ports.LayerTop)) || r.W <= 0 || r.H <= 0 { | |||
| 113 | + | continue | |||
| 114 | + | } | |||
| 115 | + | if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | |||
| 116 | + | return l.ID, lx - float64(r.X), ly - float64(r.Y) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | return 0, 0, 0 | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // onDemand reports whether id is a mapped layer surface with on-demand | |||
| 123 | + | // keyboard interactivity. | |||
| 124 | + | func (c *Core) onDemand(id WindowID) bool { | |||
| 125 | + | for _, sc := range c.screens { | |||
| 126 | + | for _, l := range sc.layers { | |||
| 127 | + | if l.ID == id { | |||
| 128 | + | return l.Keyboard == 2 | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return false | |||
| 133 | + | } | |||
M internal/core/popup.go +33 -5
| @@ -125,7 +125,7 @@ func flipY(e uint32) uint32 { | |||
| 125 | 125 | return e | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | - | // windowRect is where a window or popup is on its screen: its client | |
| 128 | + | // windowRect is where a window, layer surface or popup is on its screen: its client | |
| 129 | 129 | // area, output-local and logical. ok is false when it is not on screen. | |
| 130 | 130 | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |
| 131 | 131 | if p := c.popups[id]; p != nil { | |
| @@ -135,6 +135,12 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 135 | 135 | } | |
| 136 | 136 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | |
| 137 | 137 | } | |
| 138 | + | if sc, l, ok := c.layerOf(id); ok { | |
| 139 | + | if !shown(sc, l.Layer) { | |
| 140 | + | return nil, Rect{}, false | |
| 141 | + | } | |
| 142 | + | return sc, l.Rect, true | |
| 143 | + | } | |
| 138 | 144 | for _, sc := range c.screens { | |
| 139 | 145 | for _, pl := range sc.mon.Layout() { | |
| 140 | 146 | if pl.ID == id { | |
| @@ -272,11 +278,12 @@ func (c *Core) grabFocus() WindowID { | |||
| 272 | 278 | return 0 | |
| 273 | 279 | } | |
| 274 | 280 | ||
| 275 | - | // popupAt returns the topmost mapped popup under the output-local point. | |
| 276 | - | func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) { | |
| 281 | + | // popupAt returns the topmost mapped popup under the output-local point, | |
| 282 | + | // among layer popups (overLayers) or window popups. | |
| 283 | + | func (c *Core) popupAt(sc *screen, lx, ly float64, overLayers bool) (WindowID, float64, float64) { | |
| 277 | 284 | for i := len(c.popupOrder) - 1; i >= 0; i-- { | |
| 278 | 285 | p := c.popups[c.popupOrder[i]] | |
| 279 | - | if p == nil || !p.mapped { | |
| 286 | + | if p == nil || !p.mapped || c.onLayer(c.popupRoot(p.id)) != overLayers { | |
| 280 | 287 | continue | |
| 281 | 288 | } | |
| 282 | 289 | s, r, ok := c.windowRect(p.id) | |
| @@ -290,6 +297,27 @@ func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) | |||
| 290 | 297 | return 0, 0, 0 | |
| 291 | 298 | } | |
| 292 | 299 | ||
| 300 | + | // popupRoot is the window or layer a popup chain hangs from; id itself | |
| 301 | + | // when it is not a popup. | |
| 302 | + | func (c *Core) popupRoot(id WindowID) WindowID { | |
| 303 | + | for p := c.popups[id]; p != nil; p = c.popups[id] { | |
| 304 | + | id = p.parent | |
| 305 | + | } | |
| 306 | + | return id | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // onLayer reports whether id is a mapped layer surface. | |
| 310 | + | func (c *Core) onLayer(id WindowID) bool { | |
| 311 | + | for _, sc := range c.screens { | |
| 312 | + | for _, l := range sc.layers { | |
| 313 | + | if l.ID == id { | |
| 314 | + | return true | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | return false | |
| 319 | + | } | |
| 320 | + | ||
| 293 | 321 | // scenePopups are the mapped popups on a screen, bottom to top. | |
| 294 | 322 | func (c *Core) scenePopups(sc *screen) []ports.SceneWindow { | |
| 295 | 323 | var out []ports.SceneWindow | |
| @@ -299,7 +327,7 @@ func (c *Core) scenePopups(sc *screen) []ports.SceneWindow { | |||
| 299 | 327 | continue | |
| 300 | 328 | } | |
| 301 | 329 | if s, r, ok := c.windowRect(id); ok && s == sc { | |
| 302 | - | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true}) | |
| 330 | + | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true, OverLayers: c.onLayer(c.popupRoot(id))}) | |
| 303 | 331 | } | |
| 304 | 332 | } | |
| 305 | 333 | return out | |
| @@ -125,7 +125,7 @@ func flipY(e uint32) uint32 { | |||||
| 125 | return e | 125 | return e | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | // windowRect is where a window or popup is on its screen: its client | 128 | + | // windowRect is where a window, layer surface or popup is on its screen: its client |
| 129 | // area, output-local and logical. ok is false when it is not on screen. | 129 | // area, output-local and logical. ok is false when it is not on screen. | ||
| 130 | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | 130 | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | ||
| 131 | if p := c.popups[id]; p != nil { | 131 | if p := c.popups[id]; p != nil { | ||
| @@ -135,6 +135,12 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||||
| 135 | } | 135 | } | ||
| 136 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | 136 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | ||
| 137 | } | 137 | } | ||
| 138 | + | if sc, l, ok := c.layerOf(id); ok { | |||
| 139 | + | if !shown(sc, l.Layer) { | |||
| 140 | + | return nil, Rect{}, false | |||
| 141 | + | } | |||
| 142 | + | return sc, l.Rect, true | |||
| 143 | + | } | |||
| 138 | for _, sc := range c.screens { | 144 | for _, sc := range c.screens { | ||
| 139 | for _, pl := range sc.mon.Layout() { | 145 | for _, pl := range sc.mon.Layout() { | ||
| 140 | if pl.ID == id { | 146 | if pl.ID == id { | ||
| @@ -272,11 +278,12 @@ func (c *Core) grabFocus() WindowID { | |||||
| 272 | return 0 | 278 | return 0 | ||
| 273 | } | 279 | } | ||
| 274 | 280 | ||||
| 275 | - | // popupAt returns the topmost mapped popup under the output-local point. | 281 | + | // popupAt returns the topmost mapped popup under the output-local point, |
| 276 | - | func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) { | 282 | + | // among layer popups (overLayers) or window popups. |
| 283 | + | func (c *Core) popupAt(sc *screen, lx, ly float64, overLayers bool) (WindowID, float64, float64) { | |||
| 277 | for i := len(c.popupOrder) - 1; i >= 0; i-- { | 284 | for i := len(c.popupOrder) - 1; i >= 0; i-- { | ||
| 278 | p := c.popups[c.popupOrder[i]] | 285 | p := c.popups[c.popupOrder[i]] | ||
| 279 | - | if p == nil || !p.mapped { | 286 | + | if p == nil || !p.mapped || c.onLayer(c.popupRoot(p.id)) != overLayers { |
| 280 | continue | 287 | continue | ||
| 281 | } | 288 | } | ||
| 282 | s, r, ok := c.windowRect(p.id) | 289 | s, r, ok := c.windowRect(p.id) | ||
| @@ -290,6 +297,27 @@ func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) | |||||
| 290 | return 0, 0, 0 | 297 | return 0, 0, 0 | ||
| 291 | } | 298 | } | ||
| 292 | 299 | ||||
| 300 | + | // popupRoot is the window or layer a popup chain hangs from; id itself | |||
| 301 | + | // when it is not a popup. | |||
| 302 | + | func (c *Core) popupRoot(id WindowID) WindowID { | |||
| 303 | + | for p := c.popups[id]; p != nil; p = c.popups[id] { | |||
| 304 | + | id = p.parent | |||
| 305 | + | } | |||
| 306 | + | return id | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // onLayer reports whether id is a mapped layer surface. | |||
| 310 | + | func (c *Core) onLayer(id WindowID) bool { | |||
| 311 | + | for _, sc := range c.screens { | |||
| 312 | + | for _, l := range sc.layers { | |||
| 313 | + | if l.ID == id { | |||
| 314 | + | return true | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | return false | |||
| 319 | + | } | |||
| 320 | + | ||||
| 293 | // scenePopups are the mapped popups on a screen, bottom to top. | 321 | // scenePopups are the mapped popups on a screen, bottom to top. | ||
| 294 | func (c *Core) scenePopups(sc *screen) []ports.SceneWindow { | 322 | func (c *Core) scenePopups(sc *screen) []ports.SceneWindow { | ||
| 295 | var out []ports.SceneWindow | 323 | var out []ports.SceneWindow | ||
| @@ -299,7 +327,7 @@ func (c *Core) scenePopups(sc *screen) []ports.SceneWindow { | |||||
| 299 | continue | 327 | continue | ||
| 300 | } | 328 | } | ||
| 301 | if s, r, ok := c.windowRect(id); ok && s == sc { | 329 | if s, r, ok := c.windowRect(id); ok && s == sc { | ||
| 302 | - | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true}) | 330 | + | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true, OverLayers: c.onLayer(c.popupRoot(id))}) |
| 303 | } | 331 | } | ||
| 304 | } | 332 | } | ||
| 305 | return out | 333 | return out | ||
M internal/ports/messages.go +5 -0
| @@ -532,6 +532,8 @@ type Scene struct { | |||
| 532 | 532 | Windows []SceneWindow | |
| 533 | 533 | // Layers are drawn in slice order: background and bottom before windows, | |
| 534 | 534 | // top and overlay after. A fullscreen window covers bottom and top. | |
| 535 | + | // Window popups are drawn after the windows, layer popups | |
| 536 | + | // (SceneWindow.OverLayers) last, over every layer. | |
| 535 | 537 | Layers []SceneLayer | |
| 536 | 538 | } | |
| 537 | 539 | ||
| @@ -657,6 +659,9 @@ type SceneWindow struct { | |||
| 657 | 659 | Borderless bool | |
| 658 | 660 | // Popups are drawn from their content only: no border, no background. | |
| 659 | 661 | Popup bool | |
| 662 | + | // OverLayers popups hang from a layer surface: drawn over the top and | |
| 663 | + | // overlay layers. Window popups stay with the windows, under them. | |
| 664 | + | OverLayers bool | |
| 660 | 665 | } | |
| 661 | 666 | ||
| 662 | 667 | // State carries core → state publisher a snapshot of what is on screen, for | |
| @@ -532,6 +532,8 @@ type Scene struct { | |||||
| 532 | Windows []SceneWindow | 532 | Windows []SceneWindow | ||
| 533 | // Layers are drawn in slice order: background and bottom before windows, | 533 | // Layers are drawn in slice order: background and bottom before windows, | ||
| 534 | // top and overlay after. A fullscreen window covers bottom and top. | 534 | // top and overlay after. A fullscreen window covers bottom and top. | ||
| 535 | + | // Window popups are drawn after the windows, layer popups | |||
| 536 | + | // (SceneWindow.OverLayers) last, over every layer. | |||
| 535 | Layers []SceneLayer | 537 | Layers []SceneLayer | ||
| 536 | } | 538 | } | ||
| 537 | 539 | ||||
| @@ -657,6 +659,9 @@ type SceneWindow struct { | |||||
| 657 | Borderless bool | 659 | Borderless bool | ||
| 658 | // Popups are drawn from their content only: no border, no background. | 660 | // Popups are drawn from their content only: no border, no background. | ||
| 659 | Popup bool | 661 | Popup bool | ||
| 662 | + | // OverLayers popups hang from a layer surface: drawn over the top and | |||
| 663 | + | // overlay layers. Window popups stay with the windows, under them. | |||
| 664 | + | OverLayers bool | |||
| 660 | } | 665 | } | ||
| 661 | 666 | ||||
| 662 | // State carries core → state publisher a snapshot of what is on screen, for | 667 | // State carries core → state publisher a snapshot of what is on screen, for | ||
A packaging/arch/PKGBUILD +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | # Built by `make pkg` at the repo root, from the `make dist` tarball. | |
| 2 | + | pkgname=nefertty | |
| 3 | + | pkgver=0.0.0 | |
| 4 | + | pkgrel=1 | |
| 5 | + | pkgdesc='Minimal Wayland compositor with scrollable columns, for the TTY' | |
| 6 | + | arch=('x86_64') | |
| 7 | + | url='https://github.com/bnema/nefertty' | |
| 8 | + | license=('MIT') | |
| 9 | + | # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan. | |
| 10 | + | depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader' | |
| 11 | + | 'vulkan-driver' 'wayland' 'dbus') | |
| 12 | + | makedepends=('go') | |
| 13 | + | optdepends=('xwayland-satellite>=0.7: X11 apps' | |
| 14 | + | 'foot: default terminal' | |
| 15 | + | 'fuzzel: launcher' | |
| 16 | + | 'xdg-desktop-portal-gtk: file pickers and settings for apps') | |
| 17 | + | source=("$pkgname-$pkgver.tar.gz") | |
| 18 | + | sha256sums=('SKIP') | |
| 19 | + | ||
| 20 | + | build() { | |
| 21 | + | cd "$pkgname-$pkgver" | |
| 22 | + | export CGO_ENABLED=0 GOFLAGS='-mod=vendor -trimpath -buildvcs=false -buildmode=pie' GOPROXY=off GOTOOLCHAIN=local | |
| 23 | + | go build -ldflags "-X main.version=$pkgver" -o nefertty ./cmd/nefertty | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | check() { | |
| 27 | + | cd "$pkgname-$pkgver" | |
| 28 | + | ./nefertty version | |
| 29 | + | ./nefertty validate-config examples/config | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | package() { | |
| 33 | + | cd "$pkgname-$pkgver" | |
| 34 | + | install -Dm755 nefertty "$pkgdir/usr/bin/nefertty" | |
| 35 | + | install -Dm644 packaging/nefertty.desktop "$pkgdir/usr/share/wayland-sessions/nefertty.desktop" | |
| 36 | + | install -Dm644 packaging/nefertty-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/nefertty-portals.conf" | |
| 37 | + | install -Dm644 examples/config "$pkgdir/usr/share/doc/nefertty/config.example" | |
| 38 | + | install -Dm644 README.md "$pkgdir/usr/share/doc/nefertty/README.md" | |
| 39 | + | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/nefertty/LICENSE" | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | # Built by `make pkg` at the repo root, from the `make dist` tarball. | |||
| 2 | + | pkgname=nefertty | |||
| 3 | + | pkgver=0.0.0 | |||
| 4 | + | pkgrel=1 | |||
| 5 | + | pkgdesc='Minimal Wayland compositor with scrollable columns, for the TTY' | |||
| 6 | + | arch=('x86_64') | |||
| 7 | + | url='https://github.com/bnema/nefertty' | |||
| 8 | + | license=('MIT') | |||
| 9 | + | # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan. | |||
| 10 | + | depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader' | |||
| 11 | + | 'vulkan-driver' 'wayland' 'dbus') | |||
| 12 | + | makedepends=('go') | |||
| 13 | + | optdepends=('xwayland-satellite>=0.7: X11 apps' | |||
| 14 | + | 'foot: default terminal' | |||
| 15 | + | 'fuzzel: launcher' | |||
| 16 | + | 'xdg-desktop-portal-gtk: file pickers and settings for apps') | |||
| 17 | + | source=("$pkgname-$pkgver.tar.gz") | |||
| 18 | + | sha256sums=('SKIP') | |||
| 19 | + | ||||
| 20 | + | build() { | |||
| 21 | + | cd "$pkgname-$pkgver" | |||
| 22 | + | export CGO_ENABLED=0 GOFLAGS='-mod=vendor -trimpath -buildvcs=false -buildmode=pie' GOPROXY=off GOTOOLCHAIN=local | |||
| 23 | + | go build -ldflags "-X main.version=$pkgver" -o nefertty ./cmd/nefertty | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | check() { | |||
| 27 | + | cd "$pkgname-$pkgver" | |||
| 28 | + | ./nefertty version | |||
| 29 | + | ./nefertty validate-config examples/config | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | package() { | |||
| 33 | + | cd "$pkgname-$pkgver" | |||
| 34 | + | install -Dm755 nefertty "$pkgdir/usr/bin/nefertty" | |||
| 35 | + | install -Dm644 packaging/nefertty.desktop "$pkgdir/usr/share/wayland-sessions/nefertty.desktop" | |||
| 36 | + | install -Dm644 packaging/nefertty-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/nefertty-portals.conf" | |||
| 37 | + | install -Dm644 examples/config "$pkgdir/usr/share/doc/nefertty/config.example" | |||
| 38 | + | install -Dm644 README.md "$pkgdir/usr/share/doc/nefertty/README.md" | |||
| 39 | + | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/nefertty/LICENSE" | |||
| 40 | + | } | |||
A packaging/nefertty-portals.conf +2 -0
| @@ -0,0 +1,2 @@ | |||
| 1 | + | [preferred] | |
| 2 | + | default=gtk | |
| @@ -0,0 +1,2 @@ | |||||
| 1 | + | [preferred] | |||
| 2 | + | default=gtk | |||
A packaging/nefertty.desktop +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | [Desktop Entry] | |
| 2 | + | Name=NeferTTY | |
| 3 | + | Comment=Minimal Wayland compositor with scrollable columns | |
| 4 | + | Exec=nefertty --session | |
| 5 | + | Type=Application | |
| 6 | + | DesktopNames=nefertty | |
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | [Desktop Entry] | |||
| 2 | + | Name=NeferTTY | |||
| 3 | + | Comment=Minimal Wayland compositor with scrollable columns | |||
| 4 | + | Exec=nefertty --session | |||
| 5 | + | Type=Application | |||
| 6 | + | DesktopNames=nefertty | |||