detached d2b5051

merge: layer-popups and packaging (layer pointer, popups, on-demand focus; Arch package with Ly session)

commit
d2b505176e96443f000c269b1056556a0eef04c5
author
brice · · 2026-09-26 06:06 UTC
merge of
0b45e97 + e0d1cba · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +916 -86
M .gitignore +1 -0
@@ -4,3 +4,4 @@
44 *.out
55 coverage/
66 .worktrees/
7+/dist/
@@ -4,3 +4,4 @@
4 *.out4 *.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
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
@@ -45,3 +45,41 @@ fakes-check:
4545 arch:
4646 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
4747 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 logs1+.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 ?= 04 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-check47 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-
1414
1515 Run: `nefertty --backend=headless --timeout 5s`
1616
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+
1726 ## Configuration
1827
1928 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 ## Configuration26 ## 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 (
2323
2424 type usageError struct{ error }
2525
26+// version is set with -ldflags "-X main.version=..." by packaged builds.
27+var version string
28+
2629 func main() {
2730 tuneGC()
2831 os.Exit(runCode())
@@ -99,11 +102,13 @@ func run() error {
99102 fmt.Println("unknown")
100103 return nil
101104 }
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)"
105110 }
106- fmt.Println(version)
111+ fmt.Println(v)
107112 return nil
108113 }
109114 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
@@ -116,6 +121,7 @@ func run() error {
116121 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
117122 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
118123 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")
119125 if err := flags.Parse(os.Args[1:]); err != nil {
120126 if errors.Is(err, flag.ErrHelp) {
121127 return err
@@ -221,7 +227,7 @@ func run() error {
221227 for _, w := range warnings {
222228 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
223229 }
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})
225231 // SIGINT and SIGTERM cancel the context and are clean exits.
226232 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
227233 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 nil103 return nil
101 }104 }
102- version := info.Main.Version105+ 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 nil112 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 err127 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 = nil233 err = nil
M internal/adapters/launcher/launcher.go +1 -1
@@ -32,7 +32,7 @@ func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int)
3232 continue
3333 }
3434 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_"):
3636 values[key] = value
3737 }
3838 }
@@ -32,7 +32,7 @@ func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int)
32 continue32 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] = value36 values[key] = value
37 }37 }
38 }38 }
M internal/adapters/launcher/launcher_test.go +2 -2
@@ -14,8 +14,8 @@ import (
1414 )
1515
1616 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"}
1919 if !reflect.DeepEqual(got, want) {
2020 t.Fatalf("got %v want %v", got, want)
2121 }
@@ -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
515515 }
516516 x, y := w.Rect.X, w.Rect.Y
517517 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)
521518 continue
522519 }
523520 // 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
551548 }
552549 }
553550 }
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)
554562 addLayers(true)
563+ addPopups(true)
555564 if err := r.ensureStaging(used); err != nil {
556565 return err
557566 }
@@ -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.Y516 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 continue518 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 err565 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,
100100 }
101101 func (pointer) Release(r *wayland.Pointer) { r.Destroy() }
102102
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.
104105 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
107108 }
108109
109110 // 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() == c107+ return ok && owner == c
107 }108 }
108 109
109 // cursorImage copies the committed shm buffer of a cursor surface. No110 // 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) {
361361 s.pointers[r.Client()] = list
362362 }
363363 }
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+ }
369370 }
370371 }
371372 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()] = list361 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
106106 l.pending.keyboard = k
107107 }
108108
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+}
111134 func (l *layerSurface) AckConfigure(r *wlrlayershell.ZwlrLayerSurfaceV1, serial uint32) {
112135 for i, v := range l.serials {
113136 if v == serial {
@@ -136,6 +159,9 @@ func (l *layerSurface) unmap() {
136159 if s := l.shell.server; s.focused == l.id {
137160 s.changeFocus(0)
138161 }
162+ if s := l.shell.server; s.pointerFocus == l.id {
163+ s.changePointerFocus(0, 0, 0)
164+ }
139165 l.mapped = false
140166 l.configured = false
141167 l.acked = false
@@ -106,8 +106,31 @@ func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfac
106 l.pending.keyboard = k106 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 = false165 l.mapped = false
140 l.configured = false166 l.configured = false
141 l.acked = false167 l.acked = false
M internal/adapters/wayland/layer_test.go +125 -1
@@ -5,6 +5,7 @@ import (
55 "github.com/bnema/nefertty/internal/ports"
66 "github.com/bnema/purego-libwayland/protocol/wayland"
77 "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
8+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
89 "github.com/bnema/wlturbo"
910 "golang.org/x/sys/unix"
1011 "os"
@@ -128,7 +129,16 @@ func TestWaybarLayer(t *testing.T) {
128129 if err = os.WriteFile(style, nil, 0600); err != nil {
129130 t.Fatal(err)
130131 }
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)
132142 select {
133143 case ev := <-events:
134144 v, ok := ev.(ports.LayerChanged)
@@ -222,3 +232,117 @@ func TestXDGOutputDone(t *testing.T) {
222232 })
223233 }
224234 }
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 (
44 "github.com/bnema/nefertty/internal/ports"
55 "github.com/bnema/purego-libwayland/protocol/wayland"
66 "github.com/bnema/purego-libwayland/protocol/xdgshell"
7+ "github.com/bnema/purego-libwayland/server"
78 )
89
910 // popup is the xdg_popup role of a window. Core places it (PopupRequest →
@@ -13,8 +14,11 @@ type popup struct {
1314 w *window
1415 resource *xdgshell.Popup
1516 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
1822 // sent is set once core got the PopupRequest; done once dismissed.
1923 sent, done bool
2024 }
@@ -42,11 +46,6 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
4246 s.nextWindow++
4347 s.windows[w.id] = w
4448 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- }
5049 x.surface.role = func(buffer bool) {
5150 if x.surface.destroyed {
5251 w.unmap()
@@ -54,14 +53,21 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
5453 }
5554 switch {
5655 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+ }
5763 if !pp.sent {
5864 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})
6066 }
6167 case buffer && !w.mapped && x.acked && !pp.done:
6268 w.mapped = true
6369 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")
6571 case !buffer && w.mapped:
6672 // Unmapped, it may map again after a new initial commit.
6773 w.unmap()
@@ -88,6 +94,17 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
8894
8995 func (*popup) Destroy(*xdgshell.Popup) {}
9096
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+
91108 // Grab is only valid before the first commit; core then gives the popup the
92109 // keyboard and closes it on a click outside its popup chain.
93110 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) {
99116 // and a popup over a popup grabs only if its parent does.
100117 s := p.w.xdg.server
101118 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) {
103120 p.dismiss()
104121 return
105122 }
@@ -114,7 +131,7 @@ func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token ui
114131 }
115132 p.pos = next
116133 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})
118135 }
119136 }
120137
@@ -138,10 +155,10 @@ func (p *popup) configure(c ports.ConfigurePopup) {
138155 }
139156 }
140157
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 {
145162 var top *window
146163 for _, w := range s.windows {
147164 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 {
149166 }
150167 }
151168 if top == nil {
152- return parent.popup == nil
169+ return parent == nil || parent.popup == nil
153170 }
154171 return top == parent
155172 }
@@ -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 *window14 w *window
14 resource *xdgshell.Popup15 resource *xdgshell.Popup
15 parent *window16 parent *window
16- pos ports.Positioner17+ // layer is the parent set through zwlr_layer_surface_v1.get_popup,
17- grab bool18+ // 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 bool23 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] = w47 s.windows[w.id] = w
44 x.window = w48 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 = true64 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 = true68 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 the108 // 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.server117 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 return121 return
105 }122 }
@@ -114,7 +131,7 @@ func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token ui
114 }131 }
115 p.pos = next132 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 is158+// 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 *window162 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 == nil169+ return parent == nil || parent.popup == nil
153 }170 }
154 return top == parent171 return top == parent
155 }172 }
M internal/adapters/wayland/server.go +67 -25
@@ -363,6 +363,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
363363 case ports.PointerFocus:
364364 s.changePointerFocus(c.ID, c.X, c.Y)
365365 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+ }
366374 if w := s.windows[c.ID]; w != nil && c.ID == s.pointerFocus {
367375 if s.relativeMotion(w, c) && s.locked(w) {
368376 for _, p := range s.windowPointers(w) {
@@ -386,14 +394,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
386394 }
387395 case ports.PointerButtonTo:
388396 // 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 {
390398 s.serial++
391399 state := uint32(0)
392400 if c.Pressed {
393401 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()
395403 }
396- for _, p := range s.windowPointers(w) {
404+ for _, p := range pointers {
397405 p.SendButton(s.serial, c.TimeMsec, c.Button, state)
398406 pointerFrame(p)
399407 }
@@ -404,7 +412,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
404412 return
405413 }
406414 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 {
408417 sendAxis(p, c.Axis, steps, values)
409418 }
410419 case ports.SetKeymap:
@@ -506,15 +515,42 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
506515 }
507516 }
508517 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())
513520 }
514521 }
515522 return nil, nil
516523 }
517524
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+
518554 // keymapFile writes a sealed memfd holding the NUL-terminated keymap.
519555 func keymapFile(keymap string) (int, uint32, error) {
520556 if uint64(len(keymap))+1 > uint64(^uint32(0)) {
@@ -698,8 +734,19 @@ func (s *Server) windowPointers(w *window) []*wayland.Pointer {
698734 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
699735 return nil
700736 }
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 {
701748 var result []*wayland.Pointer
702- for _, p := range s.pointers[w.xdg.resource.Client()] {
749+ for _, p := range s.pointers[c] {
703750 if p.Resource.Alive() {
704751 result = append(result, p)
705752 }
@@ -714,25 +761,20 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
714761 // The new client sets its own cursor on enter; until then, the arrow.
715762 s.cursorSurface = nil
716763 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)
724769 }
725770 }
726771 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)
736778 }
737779 }
738780 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 = 1401 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 return412 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, nil522 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 nil735 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.Pointer748 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 = nil762 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 = 0771 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, y774+ 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 {
3434 Timeout time.Duration
3535 NoTerminal bool
3636 // 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
3841 ScreenshotDir string
3942 // Sizes are the headless outputs (width, height), left to right; empty
4043 // 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)
116119 defer xdisplay.Close()
117120 childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
118121 }
122+ if opts.Session {
123+ launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
124+ defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
125+ }
119126 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
120127 if inject != nil {
121128 inject(input)
@@ -34,7 +34,10 @@ type Options struct {
34 Timeout time.Duration34 Timeout time.Duration
35 NoTerminal bool35 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 bool37+ 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 string41 ScreenshotDir string
39 // Sizes are the headless outputs (width, height), left to right; empty42 // Sizes are the headless outputs (width, height), left to right; empty
40 // means one 1920x1080 output. With several outputs, screenshots go to43 // 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 (
55 "errors"
66 "fmt"
77 "github.com/bnema/nefertty/internal/ports"
8+ "maps"
89 "slices"
910 "strconv"
1011 "strings"
@@ -81,6 +82,13 @@ type Core struct {
8182 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
8283 popups map[WindowID]*popupState
8384 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
8492 }
8593
8694 func keyName(s string) string {
@@ -289,22 +297,26 @@ func (c *Core) clientRect(p Placement) Rect {
289297 return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
290298 }
291299
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.
293302 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))
294310 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()
300312 }
301- return false
313+ return m
302314 }
303315
304316 // 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.
308320 func (c *Core) keyboardFocus() WindowID {
309321 var layer WindowID
310322 for _, sc := range c.screens {
@@ -321,6 +333,12 @@ func (c *Core) keyboardFocus() WindowID {
321333 if g := c.grabFocus(); g != 0 {
322334 return g
323335 }
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+ }
324342 id, _ := c.cur().mon.Focused()
325343 return id
326344 }
@@ -503,9 +521,10 @@ func (c *Core) allLayers() []ports.LayerSurface {
503521 return all
504522 }
505523
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.
509528 func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
510529 o, ok := c.layout().At(x, y)
511530 if !ok {
@@ -513,8 +532,15 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
513532 }
514533 lx, ly := x-float64(o.X), y-float64(o.Y)
515534 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 {
518544 return id, px, py
519545 }
520546 var id WindowID
@@ -534,6 +560,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
534560 full = full || p.Fullscreen
535561 }
536562 }
563+ if id == 0 {
564+ return layerAt(sc, lx, ly, false)
565+ }
537566 return id, sx, sy
538567 }
539568
@@ -726,8 +755,23 @@ func (c *Core) Run(ctx context.Context) error {
726755 if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
727756 return nil
728757 }
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+ }
729772 // 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()) {
731775 w.FocusID(id)
732776 c.focusScreen = c.screenIndex(s.name())
733777 if err := c.publish(ctx); err != nil {
@@ -792,6 +836,7 @@ func (c *Core) Run(ctx context.Context) error {
792836 continue
793837 }
794838 before := c.cur().mon.Current()
839+ c.layerFocus = 0 // a bind acts on the windows
795840 effect := c.applyAction(action)
796841 if effect.Quit {
797842 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]*popupState83 popups map[WindowID]*popupState
83 popupOrder []WindowID84 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 false313+ return m
302 }314 }
303 315
304 // keyboardFocus is the mapped top/overlay layer with exclusive keyboard316 // keyboardFocus is the mapped top/overlay layer with exclusive keyboard
305-// interactivity and the highest ID on any output, else the focused window of317+// interactivity and the highest ID on any output, else a grabbing popup,
306-// the focused output. On-demand layers never take focus automatically. When318+// 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 WindowID321 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 g334 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 id343 return id
326 }344 }
@@ -503,9 +521,10 @@ func (c *Core) allLayers() []ports.LayerSurface {
503 return all521 return all
504 }522 }
505 523
506-// hit returns the window under the global logical point and the point in524+// hit returns the surface under the global logical point and the point in
507-// its surface coordinates. Fullscreen wins; otherwise the last visible525+// 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, py544 return id, px, py
519 }545 }
520 var id WindowID546 var id WindowID
@@ -534,6 +560,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
534 full = full || p.Fullscreen560 full = full || p.Fullscreen
535 }561 }
536 }562 }
563+ if id == 0 {
564+ return layerAt(sc, lx, ly, false)
565+ }
537 return id, sx, sy566 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 nil756 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 continue836 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 ErrQuit842 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.
7373 sort.SliceStable(placed, func(i, j int) bool { return placed[i].Layer < placed[j].Layer })
7474 return
7575 }
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 return74 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 {
125125 return e
126126 }
127127
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
129129 // area, output-local and logical. ok is false when it is not on screen.
130130 func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
131131 if p := c.popups[id]; p != nil {
@@ -135,6 +135,12 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
135135 }
136136 return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true
137137 }
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+ }
138144 for _, sc := range c.screens {
139145 for _, pl := range sc.mon.Layout() {
140146 if pl.ID == id {
@@ -272,11 +278,12 @@ func (c *Core) grabFocus() WindowID {
272278 return 0
273279 }
274280
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) {
277284 for i := len(c.popupOrder) - 1; i >= 0; i-- {
278285 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 {
280287 continue
281288 }
282289 s, r, ok := c.windowRect(p.id)
@@ -290,6 +297,27 @@ func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64)
290297 return 0, 0, 0
291298 }
292299
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+
293321 // scenePopups are the mapped popups on a screen, bottom to top.
294322 func (c *Core) scenePopups(sc *screen) []ports.SceneWindow {
295323 var out []ports.SceneWindow
@@ -299,7 +327,7 @@ func (c *Core) scenePopups(sc *screen) []ports.SceneWindow {
299327 continue
300328 }
301329 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))})
303331 }
304332 }
305333 return out
@@ -125,7 +125,7 @@ func flipY(e uint32) uint32 {
125 return e125 return e
126 }126 }
127 127
128-// windowRect is where a window or popup is on its screen: its client128+// 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}, true136 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 0278 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 continue287 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, 0297 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.SceneWindow323 var out []ports.SceneWindow
@@ -299,7 +327,7 @@ func (c *Core) scenePopups(sc *screen) []ports.SceneWindow {
299 continue327 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 out333 return out
M internal/ports/messages.go +5 -0
@@ -532,6 +532,8 @@ type Scene struct {
532532 Windows []SceneWindow
533533 // Layers are drawn in slice order: background and bottom before windows,
534534 // 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.
535537 Layers []SceneLayer
536538 }
537539
@@ -657,6 +659,9 @@ type SceneWindow struct {
657659 Borderless bool
658660 // Popups are drawn from their content only: no border, no background.
659661 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
660665 }
661666
662667 // State carries core → state publisher a snapshot of what is on screen, for
@@ -532,6 +532,8 @@ type Scene struct {
532 Windows []SceneWindow532 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 []SceneLayer537 Layers []SceneLayer
536 }538 }
537 539
@@ -657,6 +659,9 @@ type SceneWindow struct {
657 Borderless bool659 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 bool661 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, for667 // 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

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close