#710

Make always_on_top windows Wayland layer surfaces with -Dlayer-shell

Draftcesasolwants to merge intofrom

Draft: needs testing across desktops. It has only been verified on Hyprland (Arch). Each target in the matrix below still has to be tested on Fedora and Arch before this leaves draft.

Why

On Wayland the GTK host can't honour always_on_top. The X11 path (_NET_WM_STATE_ABOVE) does nothing there, and the host prints always_on_top is unavailable on this Linux compositor. Companion and overlay windows (desktop pets, HUDs, widgets) end up as ordinary floating toplevels:

  • they sit under other floating windows;
  • they live on one workspace only;
  • tiling compositors draw their border and shadow around the transparent canvas;
  • the client never learns its own position, so restore_state can't put the window back.

Most non-GNOME Wayland compositors (KDE Plasma, Sway, Hyprland, niri, river, COSMIC, labwc, Wayfire) offer wlr-layer-shell for exactly this. Related: #156 and #359 for the separate fractional-scale problem. This PR doesn't touch scaling.

What

Build: opt-in -Dlayer-shell=true (in build/app.zig and the generated build.zig in src/tooling/templates.zig)

  • It links gtk4-layer-shell ahead of gtk4, plus wayland-client.
  • It's opt-in rather than auto-detected, unlike the host's other optional libraries, because gtk4-layer-shell can't be dlopened. It interposes libwayland-client symbols and only works when linked before it, so enabling it makes it a hard runtime dependency of the binary.
  • A build without the flag is unchanged: same sources, no new link inputs.

GTK host (src/platform/linux/gtk_host.c), all behind NATIVE_SDK_HAS_LAYER_SHELL:

  • Window type: an always_on_top window becomes a layer surface when gtk_layer_is_supported():
    • TOP layer: above normal windows, below fullscreen ones, like the floating/topmost levels on the other hosts;
    • keyboard mode ON_DEMAND;
    • exclusive zone -1, so margins map onto the output's geometry;
    • namespace = the app id.
  • Fallback: GNOME, X11 sessions and NATIVE_SDK_LAYER_SHELL=0 keep today's toplevel and X11 keep-above path.
  • Drag: layer surfaces have no xdg move, so the host drags them itself from the existing window-drag press claim and the runtime start_window_drag channel.
    • Pointer travel comes from relative-pointer-unstable-v1. The protocol tables are inlined as wayland-scanner private-code would emit them, and the manager is bound on a private queue.
    • Surface-local pointer coordinates aren't usable. A compositor that animates layer moves (Hyprland's default) reports them against a mid-animation origin the client can't know, and deriving the drag from them made the window overshoot the cursor (+106 px for a +74 px move in testing).
    • Travel is clipped at the desktop edge like the cursor. Without the protocol, the host falls back to the applied-origin method.
  • Multi-monitor: mid-drag the surface stays on its output, because a remap would end the pointer grab. On release it's dropped where the travel says, on the output under it.
  • Placement and persistence: initial_placement .restored and .explicit place the layer at the given origin, clamped to a connected output. .default stays unanchored, which layer-shell centres. Window frame events report the real origin, also after a drag ends, so the existing window-state store persists and restores it with no new API.
  • Lifetimes: the window teardown clears the static drag pointer and the frame-clock after-paint handler (held through a weak pointer).

Docs: docs/content/docs/windows.mdx describes the option and its runtime fallbacks.

Open questions for maintainers

  1. Opt-in vs auto-detect. The flag defaults to false so no build picks up a runtime dependency silently. Auto-detecting with pkg-config is friendlier for local builds but surprising for redistributed binaries. Should it be off, auto, or an app-manifest field?
  2. TOP vs OVERLAY. TOP mirrors always_on_top on macOS and Windows. Apps that want to sit over fullscreen windows would need OVERLAY. Is that worth a separate option later?
  3. Keyboard mode. ON_DEMAND (focus on click) keeps keyboard shortcuts working on interactive overlays. Should activate_on_show = false map to NONE instead?

How to test

  1. Install the library:
    • Fedora: sudo dnf install gtk4-layer-shell-devel
    • Arch: sudo pacman -S gtk4-layer-shell
  2. Build this branch's CLI with zig build cli, then create a test app with native init overlay-test --framework <this checkout>.
  3. In app.json:
    • add "linux" to platforms;
    • on the window, set "width": 260, "height": 160, "titlebar": "chromeless", "transparent": true, "always_on_top": true, "resizable": false;
    • on the view, set "gpu_alpha_mode": "premultiplied".
  4. In src/app.native, change the root to <column gap="12" padding="16" background="surface" radius="lg" window-drag="true">.
  5. Build with native build -Dlayer-shell=true and run ./zig-out/bin/overlay-test.
  6. Regression builds:
    • the plain native build must behave exactly as main;
    • NATIVE_SDK_LAYER_SHELL=0 ./zig-out/bin/overlay-test must give the old toplevel and warning.

Useful checks:

  • hyprctl layers / swaymsg -t get_tree / niri msg layers (KDE: qdbus org.kde.KWin /KWin supportInformation) to confirm a layer surface with namespace dev.native_sdk.overlay-test;
  • readelf -d zig-out/bin/overlay-test | grep NEEDED to confirm the link (libgtk4-layer-shell.so.0 only with the flag).

Test cases

#CaseExpected with -Dlayer-shell=true
T1BuildLinks; NEEDED has libgtk4-layer-shell.so.0 ahead of libgtk-4.so.1. Without the flag: no new NEEDED.
T2Surface typeLayer surface, TOP layer, namespace = app id. GNOME/XFCE: ordinary window, unchanged from main.
T3StackingStays above normal and floating windows; below fullscreen ones.
T4WorkspacesVisible on every workspace of its output.
T5DecorationsNo compositor border, shadow or titlebar; transparent corners stay transparent.
T6Dragwindow-drag area follows the cursor exactly, also with compositor animations on. A plain click doesn't move it.
T7Screen edgePush past the outer edge and come back: the window follows the cursor at once and ends at cursor minus grab offset.
T8Multi-monitorDragging onto another output drops it there under the cursor.
T9RestoreQuit and relaunch: same origin (clamped if that output is gone).
T10InteractionButtons and toggles inside still work; keyboard input works after clicking it.
T11Opt-outNATIVE_SDK_LAYER_SHELL=0: old toplevel plus the existing always_on_top is unavailable warning.
T12Default buildPlain native build: identical behaviour to main.

Test matrix

✅ pass · ❌ fail · ➖ n/a (expected fallback) · ⬜ not run

TargetHostSessionT1T2T3T4T5T6T7T8T9T10T11T12
Hyprland 0.56 (reference)ArchWayland✅✅✅✅✅✅✅✅✅✅✅✅
KDE PlasmaFedoraWayland⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
KDE PlasmaArchWayland⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
GNOME (regression: fallback)FedoraWayland⬜⬜➖➖➖⬜➖➖➖⬜⬜⬜
GNOME (regression: fallback)ArchWayland⬜⬜➖➖➖⬜➖➖➖⬜⬜⬜
niriFedoraWayland⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
niriArchWayland⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
SwayFedoraWayland⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
SwayArchWayland⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
XFCE (regression: X11)FedoraX11⬜⬜⬜➖➖⬜➖➖➖⬜⬜⬜
XFCE (regression: X11)ArchX11⬜⬜⬜➖➖⬜➖➖➖⬜⬜⬜

On GNOME and XFCE the layer path must stay inactive. There, T2 means "an ordinary window, as on main", T3 on XFCE means the existing X11 keep-above still works, and T6 means the compositor-driven drag still works.

Checklist

  • Hyprland (Arch): reference, tested during development
  • KDE Plasma (Fedora)
  • KDE Plasma (Arch)
  • GNOME (Fedora): regression, fallback unchanged
  • GNOME (Arch): regression, fallback unchanged
  • niri (Fedora)
  • niri (Arch)
  • Sway (Fedora)
  • Sway (Arch)
  • XFCE (Fedora): regression, X11 keep-above unchanged
  • XFCE (Arch): regression, X11 keep-above unchanged
  • scripts/gate.sh fast green (see the gate note below)
  • Maintainer decision on the open questions

Verification so far (Hyprland 0.56, Arch, two monitors at scale 1 and 1.333, layer animations on)

  • Drags: cursor vs window travel +99/+99, -122,+91 / -122,+92, -1932 / -1931.
  • Edge push-and-return: ends exactly at cursor minus grab offset.
  • Cross-monitor: dropped onto the other output under the cursor.
  • Restore: relaunch restores the dragged origin through the window-state store.
  • Fallbacks: NATIVE_SDK_LAYER_SHELL=0 and the default build both give the existing toplevel and warning. The default build's NEEDED list is unchanged.
  • Origin: this was first built and run as a downstream SDK patch for a desktop-pet app (crafter-station/petdex#828). That's where the overshoot, edge, teardown and monitor re-layout issues were found and fixed.

Gate

scripts/gate.sh fast on Arch:

  • Pass: zig-validate, examples-native, examples-frontends, ts-services-frontend, ts-services-e2e, surface-claims, docs-install, docs-check.
  • zig-test: failed once inside the gate, then passed when re-run on its own (zig build test, exit 0). I don't have the log of the first failure.
  • examples-mobile: fails because this machine has no Android SDK (no Android SDK at …, so the aarch64-linux-android build of examples/mobile-canvas aborts). Unrelated to this diff.

Activity

2
vercel[bot]16d ago

@cesasol is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

This pull request is a draft

Merging is unavailable until it is marked ready for review.