From a8b676381c009dc88b642f1d4edb81ca2eb95c86 Mon Sep 17 00:00:00 2001 From: Jeff Hagadorn Date: Wed, 5 Aug 2026 14:32:41 -0700 Subject: [PATCH 1/2] feat: Add gnome overlay backend so that the tool... well, actually works on gnome. --- CLAUDE.md | 20 +- docs/index.md | 17 +- docs/installation.md | 35 ++- internal/gui/backend.go | 11 +- internal/gui/controls.go | 40 ++- internal/gui/gui.go | 51 +++- internal/gui/layershell/layershell.go | 15 +- internal/gui/overlay/overlay.go | 343 ++++++++++++++++++++++++++ internal/gui/tdp.go | 5 +- internal/panelgeom/panelgeom.go | 99 ++++++++ internal/panelgeom/panelgeom_test.go | 212 ++++++++++++++++ 11 files changed, 823 insertions(+), 25 deletions(-) create mode 100644 internal/gui/overlay/overlay.go create mode 100644 internal/panelgeom/panelgeom.go create mode 100644 internal/panelgeom/panelgeom_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 983904f..5bfaddd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,10 @@ Flow Z13 via the `z13ctl` daemon. It slides in from the right edge of the screen Armoury Crate button (KEY_PROG3) is pressed. The daemon broadcasts `gui-toggle` events over a subscribe socket; this GUI listens for them. -It has two display backends: +It has three display backends: - **Layer-shell** (KDE/Wayland): margin-based slide animation +- **Overlay** (GNOME and any compositor without layer-shell): fullscreen + transparent window, drawer right-aligned inside it, click-through everywhere else - **Gamescope** (Steam Gaming Mode): X11 overlay via `STEAM_OVERLAY` atom - Module: `github.com/dahui/z13gui` @@ -45,6 +47,9 @@ internal/gui/fonts/ font.go Embedded Inter font loading internal/gui/layershell/ layershell.go Layer-shell display backend (KDE/Wayland) +internal/gui/overlay/ + overlay.go Fullscreen transparent click-through backend + (GNOME/Mutter and anything without layer-shell) internal/gui/gamepad/ gamepad.go evdev gamepad reader; device classification + EVIOCGRAB steam.go Steam PID discovery; drives the hidraw blocker @@ -73,6 +78,7 @@ internal/keyrepeat/ Tracker: which held direction owns the gamepad a internal/colorconv/ hex <-> HSL/RGB conversion and colour validation internal/lighting/ RGB mode resolution, per-mode controls, defaults internal/uiscale/ Gamescope UI scale factor (cannot live in the cgo package) +internal/panelgeom/ Overlay backend panel rectangle + slide interpolation internal/startup/ CLI arg scanning + split-level slog handler internal/togglegate/ Debounce helper for duplicate gui-toggle bursts contrib/ @@ -82,6 +88,18 @@ contrib/ ## Key architectural decisions +- **Never call into gtk4-layer-shell without checking `IsSupported()` first.** + `zwlr_layer_shell_v1` is a **wlroots** extension, not part of `wayland-protocols`: + KWin, Hyprland and Sway implement it, GNOME's Mutter never has and has no plan to. + Installing `gtk4-layer-shell` does not help — that is the *client* library; the + protocol has to come from the compositor. The failure is silent, which is what + made issue #16 hard: `gtk_layer_init_for_window` logs one `G_LOG_LEVEL_WARNING` + and returns, then every `SetLayer`/`SetAnchor`/`SetMargin`/`SetMonitor`/ + `SetKeyboardMode` warns once and no-ops. Nothing aborts, so the drawer came up + as an unanchored window — and since the anchors were the only thing supplying a + height, it collapsed to a ~320px box in the middle of the screen. `gui.go`'s + `layerShellUsable()` gates this, and checks the GDK backend *before* calling + `IsSupported()`, which asserts on a non-Wayland display. - **Layer-shell** (KDE): `github.com/diamondburned/gotk4-layer-shell/pkg/gtk4layershell` (NOT `gtklayershell` which is GTK3). pkg-config name: `gtk4-layer-shell-0`. - **Anchor**: right + top + bottom edges. Top/bottom margins set to 5% of screen height diff --git a/docs/index.md b/docs/index.md index 164a627..d1e157a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,19 +28,32 @@ touches HID devices or sysfs directly. ## Display backends -Two backends are supported, selected automatically based on the session +Three backends are supported, selected automatically based on the session environment: - **Layer-shell** (KDE Plasma, Hyprland, Sway) — Wayland layer-shell overlay with margin-based slide animation and focus-loss dismiss +- **Overlay** (GNOME, and any compositor without layer-shell) — a fullscreen, + transparent window with the drawer against the right edge. Everything outside + the drawer is click-through, so the rest of the desktop stays usable - **Gamescope** (Steam Gaming Mode) — X11 overlay via the `STEAM_OVERLAY` atom with opacity-based visibility and a click-to-dismiss backdrop +!!! note "Why GNOME needs a different backend" + + The layer-shell protocol (`zwlr_layer_shell_v1`) is a wlroots extension, not + part of `wayland-protocols`. KWin, Hyprland and Sway implement it; GNOME's + Mutter never has, taking the position that panels belong in Shell extensions. + Installing `gtk4-layer-shell` does not change this — that library is the + *client* side, and the protocol has to come from the compositor. z13gui + detects this at startup and uses the overlay backend instead. + --- ## Requirements -- Wayland compositor with layer-shell support, or gamescope (Steam Gaming Mode) +- A Wayland compositor (layer-shell is used when available; GNOME and others get + the overlay backend), or gamescope (Steam Gaming Mode) - GTK 4 and gtk4-layer-shell libraries (see [Installation](installation.md#runtime-dependencies) for distro package names) - [z13ctl](https://github.com/dahui/z13ctl) daemon running diff --git a/docs/installation.md b/docs/installation.md index 16eef3d..7e0d060 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,10 @@ ## Prerequisites - Linux kernel (x86_64) -- Wayland compositor with layer-shell support, or gamescope (Steam Gaming Mode) +- A Wayland compositor, or gamescope (Steam Gaming Mode). Compositors with + layer-shell support (KDE Plasma, Hyprland, Sway) get the drawer as a true + edge-anchored panel; GNOME and any other compositor without it get the + [overlay backend](index.md#display-backends) instead - [z13ctl](https://github.com/dahui/z13ctl) installed and daemon running ### Runtime dependencies @@ -244,6 +247,33 @@ Make sure the z13ctl daemon is running: systemctl --user status z13ctl.service ``` +Then check the log — see the note below on `--user`, which is required. + +**Which display backend am I on?** + +The startup log names it. Run z13gui from a terminal and read the first lines: + +```sh +z13gui --debug +``` + +Look for `backend mode=layer-shell`, `mode=overlay` or `mode=gamescope`. On +GNOME, `mode=overlay` is expected and correct: Mutter does not implement the +`zwlr_layer_shell_v1` protocol, so the drawer is drawn as a transparent +click-through overlay rather than an edge-anchored panel. + +**Reading the log: `--user` is required** + +z13gui runs as a systemd **user** unit (installed to `/usr/lib/systemd/user/` by +the distro packages), so its output goes to the user journal. Without `--user`, +`journalctl` searches system units, finds no such unit, and prints +`-- No entries --` — which looks like the program never ran: + +```sh +journalctl --user -u z13gui -n 50 # correct +sudo journalctl -u z13gui # WRONG: reads system units, prints nothing +``` + **Service fails to start** Check the journal: @@ -279,4 +309,5 @@ ls "$XDG_RUNTIME_DIR/$GAMESCOPE_WAYLAND_DISPLAY" ``` If the socket is missing (stale environment from a previous Gaming Mode session), -z13gui automatically falls back to Wayland layer-shell mode. +z13gui automatically falls back to the Wayland path — layer-shell where the +compositor supports it, the overlay backend otherwise. diff --git a/internal/gui/backend.go b/internal/gui/backend.go index 84f8a57..bcf9252 100644 --- a/internal/gui/backend.go +++ b/internal/gui/backend.go @@ -6,8 +6,10 @@ package gui import "github.com/diamondburned/gotk4/pkg/gtk/v4" // Backend abstracts display-mode-specific window management. -// Implementations: layershell.Backend (Wayland compositors) and -// gamescope.Backend (X11 overlay for Steam Gaming Mode). +// Implementations: layershell.Backend (Wayland compositors implementing +// zwlr_layer_shell_v1 — KWin, Hyprland, Sway), overlay.Backend (a fullscreen +// transparent click-through window for those that do not, GNOME/Mutter above +// all), and gamescope.Backend (X11 overlay for Steam Gaming Mode). type Backend interface { // Configure sets up the window for this display mode. Must be called // before the window is realized. @@ -18,7 +20,8 @@ type Backend interface { // WrapContent optionally wraps the drawer widget for this display mode. // Layer-shell returns it as-is; gamescope wraps it in a fullscreen - // container with a click-to-dismiss backdrop. + // container with a click-to-dismiss backdrop; overlay wraps it in a + // fullscreen GtkFixed so the drawer can be slid in from the right edge. WrapContent(drawer gtk.Widgetter) gtk.Widgetter // Show makes the drawer visible (animation, atom toggle, etc). @@ -28,7 +31,7 @@ type Backend interface { Hide() // Scale returns the factor the drawer's CSS pixel sizes are multiplied by. - // Layer-shell returns 1.0 — GTK handles scaling there. Gamescope scales its + // Layer-shell and overlay return 1.0 — GTK handles scaling there. Gamescope scales its // own CSS because GDK_SCALE would be applied twice, and anything drawn // directly rather than styled has to apply the same factor by hand or it // stays at its 1x size while everything around it grows. Valid after diff --git a/internal/gui/controls.go b/internal/gui/controls.go index ed7e7cd..fb32e51 100644 --- a/internal/gui/controls.go +++ b/internal/gui/controls.go @@ -31,6 +31,36 @@ func addTouchActivate(widget gtk.Widgetter, onTap func()) { gtk.BaseWidget(widget).AddController(gesture) } +// scrollMinContentHeight is the floor every view's scroller reports. It matches +// .fan-curve-area's min-height so the custom view's chart is never the thing +// being crushed when the drawer is short. +const scrollMinContentHeight = 240 + +// newDrawerScroll returns the vertical scroller every view uses. +// +// SetMinContentHeight is the load-bearing call. A GtkScrolledWindow reports a +// minimum height of 0, and SetVExpand only distributes surplus height rather +// than requesting any, so with nothing imposing a height from outside the +// toplevel's natural height collapses to the title row plus the bottom bar — +// which is the ~320px box in issue #16. Layer-shell hid that for two years by +// anchoring top and bottom, and gamescope hid it by going fullscreen; neither +// is a height the widget tree ever asked for. +// +// Deliberately not SetPropagateNaturalHeight(true): that would make the custom +// view (eight fan-curve points, three TDP sliders, telemetry) request a natural +// height taller than the screen, trading no default size for a bad one. +// +// The three views shared five identical lines before this existed, which is the +// shape of bug where a fix lands on two call sites out of three. +func newDrawerScroll(child gtk.Widgetter) *gtk.ScrolledWindow { + scroll := gtk.NewScrolledWindow() + scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) + scroll.SetVExpand(true) + scroll.SetMinContentHeight(scrollMinContentHeight) + scroll.SetChild(child) + return scroll +} + // buildContent builds the scrolled content box and returns it as the window child. // Content, theme view, and color picker view are in a gtk.Stack so views can be // swapped for gamepad navigation (and in gamescope where popovers don't work). @@ -91,10 +121,7 @@ func (w *Window) buildContent() gtk.Widgetter { // Set initial visibility based on default mode (static). w.syncModeVis() - scroll := gtk.NewScrolledWindow() - scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - scroll.SetVExpand(true) - scroll.SetChild(inner) + scroll := newDrawerScroll(inner) w.mainScroll = scroll // Stack with main, theme, and color views — used in both modes. @@ -211,10 +238,7 @@ func (w *Window) buildThemeView() *gtk.Box { content.SetMarginEnd(12) w.appendThemeChoices(content) - scroll := gtk.NewScrolledWindow() - scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - scroll.SetVExpand(true) - scroll.SetChild(content) + scroll := newDrawerScroll(content) w.themeScroll = scroll view.Append(scroll) return view diff --git a/internal/gui/gui.go b/internal/gui/gui.go index fae57d0..3fa5b94 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -4,7 +4,9 @@ // Package gui implements the GTK4 overlay drawer for z13gui. // It provides the main Window type that handles daemon state synchronization, // GTK widget construction, and theming. Display-mode-specific concerns -// (layer-shell vs gamescope X11 overlay) are delegated to Backend implementations. +// (layer-shell, the gamescope X11 overlay, or the fullscreen click-through +// overlay used where layer-shell is unavailable) are delegated to Backend +// implementations. package gui import ( @@ -12,6 +14,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "sync/atomic" "time" @@ -21,9 +24,11 @@ import ( "github.com/dahui/z13gui/internal/gui/gamepad" "github.com/dahui/z13gui/internal/gui/gamescope" "github.com/dahui/z13gui/internal/gui/layershell" + "github.com/dahui/z13gui/internal/gui/overlay" "github.com/dahui/z13gui/internal/power" "github.com/dahui/z13gui/internal/theme" "github.com/dahui/z13gui/internal/togglegate" + "github.com/diamondburned/gotk4-layer-shell/pkg/gtk4layershell" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" @@ -186,6 +191,32 @@ type Window struct { colorFocusItems []focusItem // focus grid for HSL color picker view } +// layerShellUsable reports whether this session can actually use the layer-shell +// protocol. +// +// The GDK backend is checked before asking gtk4-layer-shell, because +// gtk_layer_is_supported() runs a g_return_val_if_fail on the display being a +// GdkWaylandDisplay. On X11 that assertion fires and GLib logs it at +// G_LOG_LEVEL_CRITICAL — so simply calling it would put +// "assertion 'GDK_IS_WAYLAND_DISPLAY(gdk_display)' failed" in the journal of +// every X11 session, immediately before z13gui went on to do the right thing. +// Diagnosing issue #16 was hard enough without the fix adding its own scary +// line to the logs. +func layerShellUsable() bool { + display := gdk.DisplayGetDefault() + if display == nil { + slog.Warn("no GDK display available; assuming layer-shell is unusable") + return false + } + // e.g. "GdkWaylandDisplay", "GdkX11Display". + if backend := display.TypeFromInstance().Name(); !strings.Contains(backend, "Wayland") { + slog.Debug("GDK is not using the Wayland backend, so layer-shell cannot apply", + "gdkDisplay", backend) + return false + } + return gtk4layershell.IsSupported() +} + // New creates the overlay window and attaches it to app. Called from the // GTK Activate signal. func New(app *gtk.Application) *Window { @@ -204,10 +235,24 @@ func New(app *gtk.Application) *Window { w.gtkWin = &w.win.Window // Select display backend. - if w.gamescope { + // + // Layer-shell is not something every Wayland compositor has: zwlr_layer_shell_v1 + // is a wlroots extension, not part of wayland-protocols, and GNOME's Mutter has + // never implemented it. Calling into gtk4-layer-shell anyway does not fail + // loudly — gtk_layer_init_for_window logs one G_LOG_LEVEL_WARNING and every + // later anchor/margin call quietly no-ops — so the drawer came up as an + // unanchored, unsized box in the middle of the screen (issue #16). Ask first. + switch { + case w.gamescope: w.backend = gamescope.New(w.win, w.gtkWin, drawerWidth) - } else { + case layerShellUsable(): w.backend = layershell.New(w.win, w.gtkWin, drawerWidth) + default: + slog.Info("layer-shell unavailable, using the overlay backend "+ + "(the drawer is drawn in a transparent click-through window instead of "+ + "anchored to the screen edge)", + "desktop", os.Getenv("XDG_CURRENT_DESKTOP"), "session", os.Getenv("XDG_SESSION_TYPE")) + w.backend = overlay.New(w.win, w.gtkWin, drawerWidth) } w.backend.Configure(w.visible.Load, w.hide) diff --git a/internal/gui/layershell/layershell.go b/internal/gui/layershell/layershell.go index 5e7faf7..38c054c 100644 --- a/internal/gui/layershell/layershell.go +++ b/internal/gui/layershell/layershell.go @@ -167,7 +167,20 @@ func (b *Backend) Configure(isVisible func() bool, onDismiss func()) { }) b.gtkWin.AddController(key) - slog.Info("backend", "mode", "layer-shell") + // Report what actually happened, not what was attempted. This line used to + // be unconditional, so a session where gtk_layer_init_for_window had already + // failed still logged a clean "mode=layer-shell" — the single most + // misleading line in the diagnostic path, and why issue #16 looked like a + // rendering bug rather than a missing protocol. gui.go only builds this + // backend when IsSupported() is true, so a false here means the answer + // changed underneath us and every anchor and margin call above was a no-op. + if !gtk4layershell.IsSupported() { + slog.Error("layer-shell became unavailable after the backend was selected; " + + "the drawer will not be anchored to the screen edge") + return + } + slog.Info("backend", "mode", "layer-shell", + "protocolVersion", gtk4layershell.GetProtocolVersion()) } // WrapContent returns the drawer as-is — layer-shell uses the drawer directly diff --git a/internal/gui/overlay/overlay.go b/internal/gui/overlay/overlay.go new file mode 100644 index 0000000..646ef20 --- /dev/null +++ b/internal/gui/overlay/overlay.go @@ -0,0 +1,343 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package overlay implements the display backend for Wayland compositors that +// do not implement zwlr_layer_shell_v1 — GNOME/Mutter above all, which has +// never adopted it because it is a wlroots extension rather than part of +// wayland-protocols. +// +// Core Wayland gives a client no way to position its own window, so an ordinary +// toplevel can only ever land wherever the compositor puts it (centred, on +// Mutter). This backend sidesteps positioning entirely: the window is made +// fullscreen — a standard xdg_toplevel request every compositor honours — and +// the drawer is placed against the right edge inside it, the same structure the +// gamescope backend uses. +// +// Covering the output would normally mean covering the user's screen, so the +// window is transparent everywhere except the drawer, and its *input region* is +// restricted to the drawer's rectangle. Everything outside is therefore +// click-through: pointer events reach the windows underneath and the desktop +// stays fully usable while the drawer is open. Nothing is obscured, visually or +// functionally. +// +// One consequence is deliberate. With no backdrop swallowing clicks there is no +// click-outside-to-dismiss; dismissal is Escape plus focus loss, because +// clicking another window focuses it and deactivates ours. That is the same +// gesture without the modal takeover. +package overlay + +import ( + "log/slog" + "time" + + "github.com/diamondburned/gotk4/pkg/cairo" + "github.com/diamondburned/gotk4/pkg/gdk/v4" + "github.com/diamondburned/gotk4/pkg/glib/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" + + "github.com/dahui/z13gui/internal/panelgeom" +) + +const ( + animDuration = 200 * time.Millisecond // slide duration, matches layer-shell + focusLossDelay = 200 // ms; confirmation delay before auto-hide + keyEscape = 0xff1b // GDK_KEY_Escape +) + +// Backend manages the drawer as a right-aligned panel inside a fullscreen, +// transparent, click-through window. +type Backend struct { + appWin *gtk.ApplicationWindow + gtkWin *gtk.Window + + drawerWidth int + fixed *gtk.Fixed // fills the window; positions panel + panel *gtk.Box // the drawer itself + + geomReady bool + monW, monH int + rest panelgeom.Rect // panel rectangle at rest + + progress float64 // 0 = parked off the right edge, 1 = at rest + animGen uint64 // incremented to cancel in-flight animations + animating bool + + // focusedSinceShow gates focus-loss dismiss on the window having actually + // been focused first. CLAUDE.md forbids this guard in the layer-shell + // backend, where KWin can drop focus during a keyboard-mode transition and + // never re-grant it, so the drawer would dismiss itself on first show. + // There is no keyboard-mode transition here — this is an ordinary toplevel + // that either gets focus or does not — so the guard is safe, and it is + // better than a wall-clock settle timer because it keys on the real event. + focusedSinceShow bool + + isVisible func() bool + onDismiss func() +} + +// New creates an overlay backend. drawerWidth is the drawer panel width in pixels. +func New(appWin *gtk.ApplicationWindow, gtkWin *gtk.Window, drawerWidth int) *Backend { + return &Backend{appWin: appWin, gtkWin: gtkWin, drawerWidth: drawerWidth} +} + +// Configure sets up the fullscreen window, Escape dismiss and focus-loss +// dismiss. Unlike the other two backends it does not map the window: the +// layer-shell backend keeps its surface alive to dodge a KWin ghost-surface +// artifact, and gamescope keeps its overlay mapped so gamescope can composite +// it, but a mapped fullscreen window here would sit in the task switcher and +// hold an input region across the whole desktop while the drawer is closed. +func (b *Backend) Configure(isVisible func() bool, onDismiss func()) { + b.isVisible = isVisible + b.onDismiss = onDismiss + b.appWin.SetDecorated(false) + + // Geometry is only knowable once there is a surface to ask which monitor it + // is on, which is after WrapContent has built the panel. + b.appWin.Connect("realize", func() { b.updateGeometry() }) + + key := gtk.NewEventControllerKey() + key.ConnectKeyPressed(func(keyval, _ uint, _ gdk.ModifierType) bool { + if keyval == keyEscape { + onDismiss() + return true + } + return false + }) + b.gtkWin.AddController(key) + + // Focus-loss dismiss. Deliberately not a copy of the layer-shell handler: + // its pointer-inside check and 500ms settle window exist to filter spurious + // focus drops KWin produces during layer-shell keyboard-mode transitions, + // which cannot happen to a plain toplevel. Worse, the pointer-inside check + // would actively swallow a real dismiss here — alt-tabbing away with the + // pointer resting over the drawer is a genuine focus loss. + b.appWin.Connect("notify::is-active", func() { + if b.appWin.IsActive() { + b.focusedSinceShow = true + return + } + if !b.isVisible() || b.animating || !b.focusedSinceShow { + return + } + // Re-check after a moment: compositors flicker focus during their own + // transitions (GNOME's overview animation being the obvious one). + glib.TimeoutAdd(focusLossDelay, func() bool { + if b.isVisible() && !b.appWin.IsActive() { + slog.Debug("overlay: dismissing on focus loss") + b.onDismiss() + } + return false + }) + }) + + slog.Info("backend", "mode", "overlay") +} + +// updateGeometry re-reads the monitor the window is on and resizes the panel to +// match. Called on realize and again on every Show, because the user may have +// changed resolution, scaling or monitor since the drawer was last open. +func (b *Backend) updateGeometry() { + display := gdk.DisplayGetDefault() + surfacer := b.appWin.Surface() + if display == nil || surfacer == nil { + return + } + surface, ok := surfacer.(*gdk.Surface) + if !ok || surface == nil { + return + } + monitor := display.MonitorAtSurface(surface) + if monitor == nil { + return + } + + geo := monitor.Geometry() + b.monW, b.monH = geo.Width(), geo.Height() + b.rest = panelgeom.Panel(b.monW, b.monH, b.drawerWidth, panelgeom.DefaultMarginFraction) + b.geomReady = b.rest.W > 0 && b.rest.H > 0 + if !b.geomReady { + slog.Warn("overlay: monitor reported an unusable geometry", "w", b.monW, "h", b.monH) + return + } + + // Pin to this monitor so the window covers exactly one output. + b.appWin.FullscreenOnMonitor(monitor) + + if b.panel != nil { + // The explicit height is what makes this backend immune to the + // collapsing-ScrolledWindow problem: the panel is told how tall to be + // rather than asking its content. + b.panel.SetSizeRequest(b.rest.W, b.rest.H) + } + b.applyProgress(b.progress) + slog.Debug("overlay: geometry", "monW", b.monW, "monH", b.monH, "panel", b.rest) +} + +// WrapContent puts the drawer in a GtkFixed so it can be moved horizontally +// during the slide. A box with margins cannot do this: GTK margins are clamped +// to zero or more, so there is no way to express "off the right edge". +func (b *Backend) WrapContent(drawer gtk.Widgetter) gtk.Widgetter { + b.fixed = gtk.NewFixed() + b.fixed.SetHExpand(true) + b.fixed.SetVExpand(true) + + b.panel = gtk.NewBox(gtk.OrientationVertical, 0) + b.panel.SetSizeRequest(b.drawerWidth, -1) + b.panel.Append(drawer) + + // Geometry is not known yet; realize positions it before the window is + // ever on screen. + b.fixed.Put(b.panel, 0, 0) + return b.fixed +} + +// Scale is 1.0: GTK applies the compositor's scale factor itself here, so the +// drawer's CSS pixel values are already correct. internal/uiscale must not be +// reused — it exists only because gamescope runs its own scaler on top of GTK's +// and the two would compound. +func (b *Backend) Scale() float64 { return 1.0 } + +// Show maps the window and slides the panel in from the right edge. +func (b *Backend) Show() { + b.focusedSinceShow = false + b.appWin.SetVisible(true) + b.appWin.Present() + b.updateGeometry() + b.slideTo(1, func() { + b.animating = false + // Re-apply once more after GTK has settled the layout. The first frames + // of the slide can run before the panel has been allocated, so the final + // position must be recomputed from a width that is known by now, or the + // drawer rests a few pixels off the edge for as long as it is open. + glib.IdleAdd(func() bool { + b.applyProgress(1) + b.applyInputRegion() + return false + }) + }) +} + +// Hide slides the panel out and then unmaps the window. +func (b *Backend) Hide() { + // Drop the input region first so the desktop is click-through for the whole + // slide-out rather than only once it finishes. + b.clearInputRegion() + b.slideTo(0, func() { + b.animating = false + b.appWin.SetVisible(false) + b.focusedSinceShow = false + }) +} + +// slideTo animates progress to target over animDuration. +// +// animGen mirrors the layer-shell backend's generation counter: a show landing +// mid-hide bumps it, and the in-flight tick callback sees the mismatch and stops +// instead of dragging the panel back off-screen behind the new animation. +func (b *Backend) slideTo(target float64, onDone func()) { + b.animGen++ + gen := b.animGen + b.animating = true + from := b.progress + t0 := time.Now() + + b.gtkWin.AddTickCallback(func(_ gtk.Widgetter, _ gdk.FrameClocker) bool { + if b.animGen != gen { + slog.Debug("overlay: animation cancelled", "gen", gen, "current", b.animGen) + return false + } + raw := float64(time.Since(t0)) / float64(animDuration) + if raw >= 1 { + b.applyProgress(target) + if onDone != nil { + onDone() + } + return false + } + b.applyProgress(from + (target-from)*panelgeom.Smoothstep(raw)) + return true + }) +} + +// currentRest returns the at-rest rectangle, corrected for the width GTK +// actually gave the panel. +// +// This correction is load-bearing. GtkFixed allocates every child its *natural* +// size, and SetSizeRequest only sets a minimum, so the drawer comes out wider +// than drawerWidth once .drawer's border and padding are added — about 337px for +// a 320px request. Positioning at monW-320 then pushes that extra width off the +// right of the screen, clipping the second column of every button row. +// +// The layer-shell backend never has to think about this because the compositor +// sizes the anchored surface, and it says so where it polls for "the actual +// width, which may exceed drawerWidth due to CSS borders/styling". Here nothing +// constrains the child, so the position has to follow the real width instead. +func (b *Backend) currentRest() panelgeom.Rect { + r := b.rest + w := b.panel.Width() + if w <= 0 { + return r // not allocated yet; the next frame corrects it + } + if w > b.monW { + w = b.monW + } + r.W, r.X = w, b.monW-w + return r +} + +// applyProgress moves the panel to the position for p. +func (b *Backend) applyProgress(p float64) { + b.progress = p + if !b.geomReady || b.fixed == nil || b.panel == nil { + return + } + rest := b.currentRest() + x := panelgeom.SlideX(rest, b.monW, p) + b.fixed.Move(b.panel, float64(x), float64(rest.Y)) +} + +// applyInputRegion restricts input to the panel rectangle, making the rest of +// the fullscreen window click-through. +func (b *Backend) applyInputRegion() { + if !b.geomReady || b.panel == nil { + return + } + rest := b.currentRest() + x := panelgeom.SlideX(rest, b.monW, b.progress) + // Log the rectangle actually applied, not the requested one: this is the + // width after GTK's allocation, which is the number that matters when the + // drawer looks misplaced. The "geometry" line above is logged before + // allocation and still carries the uncorrected drawerWidth. + slog.Debug("overlay: input region", "x", x, "y", rest.Y, "w", rest.W, "h", rest.H) + b.setInputRegion(cairo.RectangleNew(x, rest.Y, rest.W, rest.H)) +} + +// clearInputRegion makes the whole window click-through. +func (b *Backend) clearInputRegion() { b.setInputRegion(nil) } + +// setInputRegion applies rect as the surface's input region, or an empty region +// when rect is nil. Note the inversion: an *empty* region means the surface +// accepts no pointer input at all, which is what makes it click-through. +func (b *Backend) setInputRegion(rect *cairo.Rectangle) { + surfacer := b.appWin.Surface() + if surfacer == nil { + return + } + surface, ok := surfacer.(*gdk.Surface) + if !ok || surface == nil { + return + } + + region, err := cairo.RegionCreate() + if err != nil { + slog.Warn("overlay: could not create an input region", "err", err) + return + } + if rect != nil { + if region, err = region.CreateRectangle(rect); err != nil { + slog.Warn("overlay: could not build the panel input region", "err", err) + return + } + } + surface.SetInputRegion(region) +} diff --git a/internal/gui/tdp.go b/internal/gui/tdp.go index 1f891d3..7e0ba0a 100644 --- a/internal/gui/tdp.go +++ b/internal/gui/tdp.go @@ -562,10 +562,7 @@ func (w *Window) buildCustomView() *gtk.Box { content.Append(resetRow) - scroll := gtk.NewScrolledWindow() - scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - scroll.SetVExpand(true) - scroll.SetChild(content) + scroll := newDrawerScroll(content) w.customScroll = scroll view.Append(scroll) diff --git a/internal/panelgeom/panelgeom.go b/internal/panelgeom/panelgeom.go new file mode 100644 index 0000000..b933dfb --- /dev/null +++ b/internal/panelgeom/panelgeom.go @@ -0,0 +1,99 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package panelgeom computes the drawer panel's rectangle and slide animation +// for the overlay backend, which draws the drawer inside a fullscreen window +// instead of letting a compositor anchor it. +// +// The layer-shell backend gets this geometry for free: it names the edges to +// anchor to and the compositor works out the rest. Compositors without +// zwlr_layer_shell_v1 (Mutter, notably) offer no such thing, and core Wayland +// does not let a client position its own window at all, so the overlay backend +// covers the output and places the panel within it — which means computing the +// rectangle by hand. +// +// It is a separate package because internal/gui needs cgo and GTK4 headers, so +// `make test` cannot even compile it; anything left in there is permanently +// unverifiable. This is arithmetic with clamping and degenerate cases, which is +// exactly the sort of thing worth pinning down. +package panelgeom + +import "math" + +// DefaultMarginFraction insets the panel vertically by 1/20 of the output +// height at the top and bottom, matching the layer-shell backend's 5% margins. +const DefaultMarginFraction = 20 + +// Rect is a panel rectangle in output-relative pixels, with the origin at the +// output's top-left corner. +type Rect struct { + X, Y, W, H int +} + +// Panel returns the drawer's at-rest rectangle on an output of monW x monH: +// right-aligned, drawerWidth wide, inset from the top and bottom by +// monH/marginFraction. +// +// Every input is treated as untrusted, because these come from monitor +// geometry that GDK reports and that a hotplug can change mid-animation. A +// degenerate output yields the zero Rect rather than a negative-sized panel +// that GTK would warn about on every allocation. +// +// The width clamp matters on small outputs: an unclamped drawerWidth wider than +// the output places X at a negative coordinate, which puts the drawer's right +// edge off-screen and its controls out of reach with no way to scroll to them. +func Panel(monW, monH, drawerWidth, marginFraction int) Rect { + if monW <= 0 || monH <= 0 || drawerWidth <= 0 { + return Rect{} + } + + w := drawerWidth + if w > monW { + w = monW + } + + // A non-positive fraction means "no inset" rather than a divide by zero. + margin := 0 + if marginFraction > 0 { + margin = monH / marginFraction + } + + h := monH - 2*margin + if h <= 0 { + // The inset consumed the whole output; fall back to full height rather + // than an invisible panel. + margin, h = 0, monH + } + + return Rect{X: monW - w, Y: margin, W: w, H: h} +} + +// SlideX returns the panel's X coordinate at animation progress t, where t=0 is +// fully off the right edge of the output and t=1 is at rest. +// +// t is clamped rather than extrapolated: a tick callback can be handed a +// slightly out-of-range value when a frame lands after the animation's nominal +// end, and extrapolating there would overshoot the rest position by a visible +// jump on the final frame. +func SlideX(rest Rect, monW int, t float64) int { + if t <= 0 || math.IsNaN(t) { + return monW + } + if t >= 1 { + return rest.X + } + return int(math.Round(float64(monW) + t*(float64(rest.X)-float64(monW)))) +} + +// Smoothstep eases t with the standard 3t²-2t³ curve, so the slide starts and +// ends at rest instead of stopping abruptly. Mirrors the easing the layer-shell +// backend applies to its margin animation. +func Smoothstep(t float64) float64 { + if t <= 0 || math.IsNaN(t) { + return 0 + } + if t >= 1 { + return 1 + } + return t * t * (3 - 2*t) +} diff --git a/internal/panelgeom/panelgeom_test.go b/internal/panelgeom/panelgeom_test.go new file mode 100644 index 0000000..4fa65c6 --- /dev/null +++ b/internal/panelgeom/panelgeom_test.go @@ -0,0 +1,212 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package panelgeom + +import ( + "math" + "testing" +) + +// assertPanelValid asserts the invariants every non-degenerate panel must hold, +// rather than spot values. A panel that violates one of these is not merely +// mispositioned: it is unreachable, invisible, or makes GTK warn on every +// allocation. +func assertPanelValid(t *testing.T, r Rect, monW, monH int) { + t.Helper() + + if r.W <= 0 || r.H <= 0 { + t.Fatalf("panel has non-positive size: %+v", r) + } + if r.X < 0 || r.Y < 0 { + t.Errorf("panel starts off the top/left of the output: %+v", r) + } + if r.X+r.W > monW { + t.Errorf("panel overflows the right edge: %+v on %dx%d", r, monW, monH) + } + if r.Y+r.H > monH { + t.Errorf("panel overflows the bottom edge: %+v on %dx%d", r, monW, monH) + } + // Right-aligned: the whole point of the drawer. + if r.X+r.W != monW { + t.Errorf("panel is not flush with the right edge: %+v on %dx%d", r, monW, monH) + } + // Vertical inset is symmetric. + if top, bottom := r.Y, monH-(r.Y+r.H); top != bottom { + t.Errorf("vertical margins are asymmetric: top=%d bottom=%d (%+v)", top, bottom, r) + } +} + +func TestPanel(t *testing.T) { + tests := []struct { + name string + monW, monH, drawerWidth, margin int + }{ + {"z13 native", 2560, 1600, 320, DefaultMarginFraction}, + {"1080p", 1920, 1080, 320, DefaultMarginFraction}, + {"4k", 3840, 2160, 320, DefaultMarginFraction}, + {"portrait", 1200, 1920, 320, DefaultMarginFraction}, + {"no inset", 1920, 1080, 320, 0}, + {"negative fraction is treated as no inset", 1920, 1080, 320, -5}, + {"tiny output", 400, 300, 320, DefaultMarginFraction}, + {"drawer exactly the output width", 320, 800, 320, DefaultMarginFraction}, + {"drawer wider than the output", 240, 800, 320, DefaultMarginFraction}, + {"inset consumes the whole output", 1920, 1080, 320, 2}, + {"inset larger than the output", 1920, 1080, 320, 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Panel(tc.monW, tc.monH, tc.drawerWidth, tc.margin) + assertPanelValid(t, got, tc.monW, tc.monH) + + if tc.drawerWidth <= tc.monW && got.W != tc.drawerWidth { + t.Errorf("width %d, want the requested %d", got.W, tc.drawerWidth) + } + if tc.drawerWidth > tc.monW && got.W != tc.monW { + t.Errorf("width %d, want it clamped to the output width %d", got.W, tc.monW) + } + }) + } +} + +func TestPanelDegenerate(t *testing.T) { + // GDK can report a zero geometry for a monitor that is being hotplugged, + // and drawerWidth comes from a caller. None of these may produce a + // negative-sized rect. + tests := []struct { + name string + monW, monH, drawerWidth, margin int + }{ + {"zero width", 0, 1080, 320, DefaultMarginFraction}, + {"zero height", 1920, 0, 320, DefaultMarginFraction}, + {"negative width", -1920, 1080, 320, DefaultMarginFraction}, + {"negative height", 1920, -1080, 320, DefaultMarginFraction}, + {"zero drawer width", 1920, 1080, 0, DefaultMarginFraction}, + {"negative drawer width", 1920, 1080, -320, DefaultMarginFraction}, + {"everything zero", 0, 0, 0, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Panel(tc.monW, tc.monH, tc.drawerWidth, tc.margin); got != (Rect{}) { + t.Errorf("Panel(%d, %d, %d, %d) = %+v, want the zero Rect", + tc.monW, tc.monH, tc.drawerWidth, tc.margin, got) + } + }) + } +} + +func TestPanelDefaultMarginIsFivePercent(t *testing.T) { + // Pins the value the layer-shell backend uses, so the two backends put the + // drawer in the same place. + const monH = 1600 + got := Panel(2560, monH, 320, DefaultMarginFraction) + if want := monH / 20; got.Y != want { + t.Errorf("top margin = %d, want %d (5%% of %d)", got.Y, want, monH) + } +} + +func TestSlideXEndpoints(t *testing.T) { + const monW = 2560 + rest := Panel(monW, 1600, 320, DefaultMarginFraction) + + if got := SlideX(rest, monW, 0); got != monW { + t.Errorf("SlideX at t=0 = %d, want %d (fully off the right edge)", got, monW) + } + if got := SlideX(rest, monW, 1); got != rest.X { + t.Errorf("SlideX at t=1 = %d, want the rest position %d", got, rest.X) + } +} + +func TestSlideXClampsOutOfRange(t *testing.T) { + // A tick callback can produce a t slightly outside [0,1] when a frame lands + // after the animation's nominal end. Extrapolating there would overshoot the + // rest position and show a jump on the final frame. + const monW = 2560 + rest := Panel(monW, 1600, 320, DefaultMarginFraction) + + for _, tt := range []float64{-1, -0.001, math.NaN()} { + if got := SlideX(rest, monW, tt); got != monW { + t.Errorf("SlideX at t=%v = %d, want %d", tt, got, monW) + } + } + for _, tt := range []float64{1.001, 2, math.Inf(1)} { + if got := SlideX(rest, monW, tt); got != rest.X { + t.Errorf("SlideX at t=%v = %d, want %d", tt, got, rest.X) + } + } +} + +func TestSlideXIsMonotonic(t *testing.T) { + // The panel may never move backwards during a slide — a non-monotonic curve + // reads as a stutter. + const monW = 2560 + rest := Panel(monW, 1600, 320, DefaultMarginFraction) + + prev := SlideX(rest, monW, 0) + for i := 1; i <= 100; i++ { + got := SlideX(rest, monW, float64(i)/100) + if got > prev { + t.Fatalf("SlideX moved right at t=%.2f: %d after %d", float64(i)/100, got, prev) + } + prev = got + } + if prev != rest.X { + t.Errorf("slide ended at %d, want the rest position %d", prev, rest.X) + } +} + +func TestSlideXStaysOnScreen(t *testing.T) { + const monW = 2560 + rest := Panel(monW, 1600, 320, DefaultMarginFraction) + + for i := 0; i <= 100; i++ { + x := SlideX(rest, monW, float64(i)/100) + if x < rest.X || x > monW { + t.Errorf("SlideX at t=%.2f = %d, outside [%d, %d]", float64(i)/100, x, rest.X, monW) + } + } +} + +func TestSmoothstep(t *testing.T) { + if got := Smoothstep(0); got != 0 { + t.Errorf("Smoothstep(0) = %v, want 0", got) + } + if got := Smoothstep(1); got != 1 { + t.Errorf("Smoothstep(1) = %v, want 1", got) + } + if got := Smoothstep(0.5); math.Abs(got-0.5) > 1e-9 { + t.Errorf("Smoothstep(0.5) = %v, want 0.5", got) + } +} + +func TestSmoothstepClampsOutOfRange(t *testing.T) { + for _, tt := range []float64{-1, -0.001, math.NaN()} { + if got := Smoothstep(tt); got != 0 { + t.Errorf("Smoothstep(%v) = %v, want 0", tt, got) + } + } + for _, tt := range []float64{1.001, 2, math.Inf(1)} { + if got := Smoothstep(tt); got != 1 { + t.Errorf("Smoothstep(%v) = %v, want 1", tt, got) + } + } +} + +func TestSmoothstepIsMonotonicAndSymmetric(t *testing.T) { + prev := Smoothstep(0) + for i := 1; i <= 100; i++ { + tt := float64(i) / 100 + got := Smoothstep(tt) + if got < prev { + t.Fatalf("Smoothstep decreased at t=%.2f: %v after %v", tt, got, prev) + } + prev = got + + // Symmetric about the midpoint: ease-in and ease-out match. + if mirror := Smoothstep(1 - tt); math.Abs(got+mirror-1) > 1e-9 { + t.Errorf("Smoothstep(%.2f)+Smoothstep(%.2f) = %v, want 1", tt, 1-tt, got+mirror) + } + } +} From 56336209a08645a37107d2f419e4e5667ce7148f Mon Sep 17 00:00:00 2001 From: Jeff Hagadorn Date: Wed, 5 Aug 2026 14:38:53 -0700 Subject: [PATCH 2/2] chore(docs): Add notes on gnome --- README.md | 6 ++- docs/index.md | 90 ++++++++++++++++++++++++++++++++++++++++---- docs/installation.md | 17 +++++++-- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 322320d..97286cc 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ GTK4 overlay drawer for [z13ctl](https://github.com/dahui/z13ctl) on Wayland. `z13gui` is the graphical companion to z13ctl — a slide-out drawer triggered by the Armoury Crate button (KEY_PROG3). It renders as a Wayland layer-shell overlay (KDE Plasma, -Hyprland, Sway) or as a gamescope X11 overlay in Steam Gaming Mode. All hardware -communication goes through the z13ctl daemon. +Hyprland, Sway), as a transparent click-through overlay on compositors without layer-shell +(GNOME — see [GNOME support](https://dahui.github.io/z13gui/#gnome-support)), or as a +gamescope X11 overlay in Steam Gaming Mode. All hardware communication goes through the +z13ctl daemon. ## Install diff --git a/docs/index.md b/docs/index.md index d1e157a..3dd60b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,18 +35,92 @@ environment: with margin-based slide animation and focus-loss dismiss - **Overlay** (GNOME, and any compositor without layer-shell) — a fullscreen, transparent window with the drawer against the right edge. Everything outside - the drawer is click-through, so the rest of the desktop stays usable + the drawer is click-through, so the rest of the desktop stays usable. + See [GNOME support](#gnome-support) - **Gamescope** (Steam Gaming Mode) — X11 overlay via the `STEAM_OVERLAY` atom with opacity-based visibility and a click-to-dismiss backdrop -!!! note "Why GNOME needs a different backend" +--- + +## GNOME support + +z13gui works on GNOME, but with a slightly degraded experience compared with KDE +Plasma, Hyprland or Sway. This section covers why, and exactly what differs. + +### Why a workaround is needed + +The drawer is normally anchored to the screen edge using the **layer-shell** +protocol (`zwlr_layer_shell_v1`). That protocol is a **wlroots extension** — it +is not part of `wayland-protocols`, and not every Wayland compositor implements +it: + +| Compositor | Wayland | `zwlr_layer_shell_v1` | +|---|---|---| +| KWin (KDE Plasma) | yes | yes | +| Hyprland, Sway (wlroots) | yes | yes | +| **Mutter (GNOME)** | **yes** | **no** | + +GNOME supports Wayland perfectly well. What Mutter has never implemented is this +one extension, on the long-standing position that panels and docks belong in +GNOME Shell extensions rather than in a client-side protocol. + +!!! warning "Installing `gtk4-layer-shell` does not help" + + `gtk4-layer-shell` is the **client** side of the protocol — it is what z13gui + uses to *speak* layer-shell, and the packages already depend on it. The + protocol itself has to come from the compositor, and Mutter offers no way to + add Wayland protocols (GNOME Shell extensions cannot register Wayland + globals). No package can add layer-shell to GNOME. + +### How the workaround works + +Core Wayland deliberately gives a client no way to position its own window — the +compositor decides placement. So without layer-shell, an ordinary window simply +lands wherever Mutter puts it, which is the middle of the screen. That is +[exactly what used to happen](https://github.com/dahui/z13gui/issues/16). + +Instead, z13gui takes a **fullscreen** window — a standard request every +compositor honours, and one that needs no positioning — makes it fully +transparent, and draws the drawer against the right edge inside it. The window's +*input region* is then restricted to the drawer's rectangle, so every pixel +outside the drawer is click-through: clicks and scrolls pass straight to the +windows underneath, and the rest of the desktop keeps working normally. + +The missing protocol is detected at startup and this backend is selected +automatically. There is nothing to configure. + +### What is unchanged + +- The drawer sits against the right edge at full height, less 5% top and bottom +- The slide-in and slide-out animation +- Every control, theme and gamepad navigation behaviour +- Escape dismisses, as does clicking another window + +### What is degraded + +Because the drawer is an ordinary window rather than a compositor-managed overlay +layer: + +- **It cannot be drawn above a fullscreen application.** Under layer-shell the + drawer lives on the overlay layer, above everything; here it is a normal window + and the compositor decides stacking. +- **It belongs to the current workspace**, rather than being present on every + workspace the way a layer surface is. +- **It may appear in the window switcher (Alt-Tab) while open.** The window is + unmapped when the drawer is closed, so it only shows up while on screen. +- **GNOME's top bar may be hidden while the drawer is open**, since Mutter hides + it for fullscreen windows. +- **There is no dedicated click-outside backdrop.** Dismissal is Escape, or + clicking another window — which works because that window takes focus. + +None of this affects the controls themselves: every hardware feature behaves +identically on GNOME. + +### Prefer the full experience? - The layer-shell protocol (`zwlr_layer_shell_v1`) is a wlroots extension, not - part of `wayland-protocols`. KWin, Hyprland and Sway implement it; GNOME's - Mutter never has, taking the position that panels belong in Shell extensions. - Installing `gtk4-layer-shell` does not change this — that library is the - *client* side, and the protocol has to come from the compositor. z13gui - detects this at startup and uses the overlay backend instead. +Log into a session whose compositor implements layer-shell — KDE Plasma, Hyprland +and Sway are all packaged for Fedora and every other major distribution. z13gui +switches back to the layer-shell backend automatically. --- diff --git a/docs/installation.md b/docs/installation.md index 7e0d060..a45fa79 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,8 +5,9 @@ - Linux kernel (x86_64) - A Wayland compositor, or gamescope (Steam Gaming Mode). Compositors with layer-shell support (KDE Plasma, Hyprland, Sway) get the drawer as a true - edge-anchored panel; GNOME and any other compositor without it get the - [overlay backend](index.md#display-backends) instead + edge-anchored panel; GNOME and any other compositor without it get the overlay + backend instead, which works but is slightly degraded — see + [GNOME support](index.md#gnome-support) - [z13ctl](https://github.com/dahui/z13ctl) installed and daemon running ### Runtime dependencies @@ -260,7 +261,17 @@ z13gui --debug Look for `backend mode=layer-shell`, `mode=overlay` or `mode=gamescope`. On GNOME, `mode=overlay` is expected and correct: Mutter does not implement the `zwlr_layer_shell_v1` protocol, so the drawer is drawn as a transparent -click-through overlay rather than an edge-anchored panel. +click-through overlay rather than an edge-anchored panel. See +[GNOME support](index.md#gnome-support) for what that changes. + +**The drawer is a small box in the middle of the screen** + +This affects v1.3.0 and earlier on GNOME. Those versions called into layer-shell +without checking whether the compositor implements it; every anchoring call +silently did nothing, and since the anchors were the only thing giving the drawer +a height, it collapsed into a small unusable window +([#16](https://github.com/dahui/z13gui/issues/16)). Upgrade to the latest +release, which detects this and uses the overlay backend instead. **Reading the log: `--user` is required**