srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/docs/IMPLEMENTATION_STATUS.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/IMPLEMENTATION_STATUS.md')
-rw-r--r--docs/IMPLEMENTATION_STATUS.md1484
1 files changed, 1480 insertions, 4 deletions
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index efecf4a..43e2c5f 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -540,8 +540,14 @@ built them:
its declared hotspot, so an I-beam over text or a hand over a link shows
the app's image rather than srdwm's arrow. The built-in arrow remains the
fallback for when no client has set one (over decorations and the
- desktop). Named shapes (`CursorIcon::Text` etc.) still fall back to the
- arrow; most toolkits set a surface, so this is rarely visible.
+ desktop). Named shapes (`CursorIcon::Named`, e.g. what a client requests
+ via `wp_cursor_shape_v1` instead of uploading a surface) now render as
+ real per-shape bitmaps too - text (I-beam), and the four resize
+ directions (`ew`/`ns`/`nesw`/`nwse`) - rather than falling back to the
+ arrow; everything else still uses the arrow. The WM itself also drives
+ this: hovering a resize edge or actively resizing sets the matching
+ shape even for clients that never touch the cursor protocol themselves
+ (`crates/wayland/src/input.rs`'s `update_cursor_shape`).
- **Key repeat for bindings** (`srd.bind_repeat`, Hyprland's `binde`).
Held volume/brightness keys and switcher cycling now repeat at the seat's
own rate (200ms delay, 25/s). Driven from the poll loop rather than a
@@ -561,6 +567,1478 @@ built them:
why Hyprland ships `extend_border_grab_area`.
- **Config path** is now `~/.config/srd` (or `$XDG_CONFIG_HOME/srd`),
not `~/.config/srdwm/srd` - the extra level said the same thing twice.
+- **`wl_pointer.frame` was never sent.** `input.rs`'s motion/button handlers
+ called `PointerHandle::motion`/`button` correctly, but never followed up
+ with `PointerHandle::frame` - confirmed by reading smithay's own
+ `DefaultGrab`: `motion`/`button` there just forward to the handle, they
+ never call `frame` themselves, so it's entirely on the compositor to send
+ it. Per protocol (required since `wl_pointer` v5; this compositor
+ advertises v9) `frame` is what tells a client "the events since the last
+ one are a single atomic update, act on them now" - without it, any
+ client that correctly waits for it (which is most real ones, including
+ Firefox and wezterm, confirmed live: neither registered a click or a
+ drag-selection with the cursor squarely on the target) never actually
+ processes motion or button state it was sent. The one place `frame` was
+ already being sent - the scroll/axis path - worked the whole time,
+ which is why this went unnoticed for so long: motion and clicks looked
+ fine from the compositor's own side (cursor tracked correctly, our own
+ decoration hit-testing and window management never touch this path at
+ all, since a decoration click is intercepted before ever reaching a
+ client), and every mouse-only window-management item in the list above
+ still worked perfectly, since none of it depends on a client ever
+ processing anything. Only clicks/drags that needed to reach a client's
+ *content* were silently inert. This is very likely the real root cause
+ behind most of a night's worth of "clicking/scrolling doesn't work"
+ reports that survived several other real, necessary fixes (subsurface
+ routing, decoration geometry, `app_id` never being set for native
+ Wayland windows) without going away.
+- **Undecorated windows had a phantom titlebar.** `ResizeEdge::hit_test`
+ applied its top-`TITLEBAR_HEIGHT`-band/button logic unconditionally,
+ regardless of `Window.decorated` - a window's allocated geometry always
+ reserves that space (placement doesn't shrink it just because decoration
+ later gets turned off), so for an undecorated window, hit-testing still
+ swallowed clicks in that band as a phantom drag/close/maximize/minimize
+ hit instead of ever reaching the client. Only became visible once
+ `decorated = false` rules could actually apply to anything (see the
+ `app_id` fix above) - Firefox's own tab strip/URL bar live exactly in
+ that band. Fixed by gating the band/button check on `decorated`; plain
+ resize-from-edge still applies either way. Exposed a second, previously
+ unreachable gap while fixing it: `resize_edge_at`'s match arms had no
+ case for a plain top edge (only the two top corners), so a window with no
+ titlebar to intercept top-area clicks at all still couldn't resize from
+ a plain top-edge drag - added.
+- **Cursor movement alone triggers a full frame-callback broadcast to every
+ mapped window on an output, not just ones near the cursor.** Found live:
+ `wezterm-gui --class scratchpad` confirmed via repeated `ps` sampling to
+ be sustaining ~140% CPU continuously on an otherwise-idle session, on a
+ machine already under real memory pressure (1.3GB swap in use of 3.7GB
+ total). Root cause: the has-damage gate added earlier (frame callbacks
+ only sent `if has_damage`, replacing a prior unconditional-every-frame
+ send) operates on a single output-wide boolean. Moving the pointer
+ legitimately damages the small region around it (old position needs
+ redrawing without the cursor, new position with it - this part of
+ smithay's damage tracking is correct and not a bug), but that alone
+ currently marks the whole output "damaged" and every mapped window gets
+ told to redraw, whether or not the cursor is anywhere near its content.
+ Ruled out one candidate mechanism directly from
+ `smithay-0.7.0/src/backend/renderer/element/memory.rs`:
+ `MemoryRenderBufferRenderElement::from_buffer()`'s `Id` is cloned from
+ the underlying `MemoryRenderBuffer`'s own stable id, not freshly
+ generated per call, so cached buffers (cursor bitmaps, per-window
+ decoration/border buffers) do have stable identity across frames --
+ the damage is real, not an artifact of identity churn. **Fixed** with the
+ per-window damage-region intersection this entry originally called for,
+ turns out `smithay::backend::renderer::damage`'s own `RenderOutputResult`
+ already hands back the exact physical-space damage rectangles it drew
+ from (`.damage: Option<&Vec<Rectangle<i32, Physical>>>`) - no second
+ damage tracker or double render needed. `elements.rs`'s
+ `windows_touched_by_damage` filters `space.elements()` down to windows
+ whose `Space::element_geometry` (converted to physical space via the
+ output's own scale) overlaps at least one of those rects, and both
+ backends' frame-callback dispatch now iterates that instead of every
+ mapped window. udev.rs's per-head loop had to carry the damage rects
+ alongside each presented `Output` into `presented` (previously just
+ `Vec<Output>`), since the frame-callback loop runs after `udev`'s mutable
+ borrow ends and needs that frame's damage by then. **Verified live**: the
+ exact reproducer from the original finding (`wezterm-gui --class
+ scratchpad`, idle) measured at 4-6% CPU on the real session after this
+ build went live, down from the ~140% recorded before the fix.
+ **Follow-up regression, found and fixed the same day**: narrowing which
+ windows get a callback to damage-overlap alone can *starve* a window
+ instead of merely under-notifying it. `send_frame` only answers a
+ *pending* `wl_surface.frame` request; GTK's frame-clock model (Firefox's
+ Wayland vsync source included) paces every repaint through that
+ callback, even the very first one after being idle - there is no
+ "just commit immediately" fallback path. If a window's *own* new content
+ is what would produce the next frame's damage, but producing it needs a
+ callback this filter is withholding because the *previous* frame's
+ damage didn't overlap it, nothing ever arrives to unstick it - reported
+ live as clicks in Firefox intermittently doing nothing until the cursor
+ was moved again (moving the cursor across the window is what
+ incidentally supplied overlapping damage). Fixed by adding
+ `always_notify` to `windows_touched_by_damage`: the focused window and
+ whatever window is currently under the pointer always get a callback,
+ damage-overlap or not - a fixed, small cost (at most two windows)
+ covering exactly the two cases user input targets.
+ **Second follow-up, same day**: that first version still didn't work --
+ `always_notify` was folded into the loop over `presented` (the outputs
+ that actually had damage this tick), so it only ever ran on a tick that
+ *already* had damage from something else. The one tick it needed to run
+ on - the output has *no* damage at all, cursor stationary, nothing else
+ happening - is exactly the tick that loop never executes for. Reported
+ live as clicks in Firefox still doing nothing at all, not intermittently.
+ Fixed by moving the `always_notify` frame-send into its own pass, after
+ and independent of the `presented` loop, run unconditionally (only
+ gated on `!locked`) every tick regardless of whether anything presented.
+ `windows_touched_by_damage` itself went back to a pure damage-overlap
+ filter with no `always_notify` parameter, now that the two mechanisms
+ are fully independent - see its doc comment.
+- **Two precise, single-function omissions, found in the same investigation
+ (a codebase-wide audit prompted by "clicking still doesn't work" and
+ "window bars still look detached" both persisting after everything
+ above), that together explain both symptoms for the two cases they cover
+ (native Wayland windows; XWayland windows) far more completely than the
+ frame-callback fixes above did on their own:**
+ - **Drag/resize end never told the compositor about the final snap.**
+ `handle_pointer_button`'s button-release branch called
+ `WindowManager::end_drag`/`end_resize` and stopped - it never called
+ `sync_geometry` afterward. `end_drag` can snap the geometry one more
+ time after the last `update_drag` already moved the window
+ (`SmartPlacement::snap_zone`: dragging to a screen edge or the top,
+ exactly the ordinary "tile left/right" and "drag-to-maximize"
+ gestures, not a rare corner case). The border and titlebar redraw
+ fresh from live `Window.geometry` every single frame, so they jumped
+ to the snapped rect immediately; the client's actual mapped surface,
+ driven only by `sync_geometry`'s `space.map_element`/
+ `xdg_toplevel.configure`, stayed wherever the drag physically
+ stopped - decoration visibly detached from its own window's content,
+ persisting until something unrelated (any keypress, a new window)
+ happened to trigger a `dirty`-driven resync. Click routing desynced
+ the same way in the meantime: `hit_test`/`window_at` read the
+ now-snapped geometry while `space.element_under` still read the stale
+ pre-snap position, so clicks in the visually-snapped zone resolved
+ against the wrong rect. The X11 backend already did this correctly
+ (`crates/x11/src/lib.rs`'s `ButtonRelease` handler calls
+ `sync_geometry` right after `end_drag`/`end_resize`) - `input.rs` is
+ explicitly the module shared by both backends for exactly this kind of
+ logic, and this one call site never got the same fix ported over.
+ Fixed by capturing `wm.focused_id()` before ending the drag/resize
+ (reliable: `start_drag`/`start_resize` both focus the window they
+ grab, and nothing else can change focus while a grab has the pointer
+ captured) and calling `state.sync_geometry(id)` after, mirroring the
+ X11 pattern exactly.
+ - **XWayland windows were never reconfigured past their initial map.**
+ `sync_geometry` only had a branch for `w.toplevel()` (native
+ xdg-shell) - there was no `w.x11_surface()` branch calling
+ `X11Surface::configure()` at all. `space.map_element` still moved
+ smithay's own tracked position (hit-testing/stacking stayed nominally
+ consistent) and the border/titlebar still redrew at the new
+ `Window.geometry` (both read it fresh every frame), but the real X11
+ client window was never told to move or resize - confirmed by
+ grepping the whole crate: `X11Surface::configure()` was called exactly
+ once anywhere, at initial mapping (`xwayland.rs`'s
+ `finish_x11_window_setup`), never again after. Every drag, resize,
+ maximize, edge-snap, or tiling re-layout of an XWayland-backed app
+ (any X11-only client - xterm, many GTK3/Qt5/Java apps, anything not
+ forced into native-Wayland mode) left its actual content frozen at its
+ original mapped size and position *forever*, while srdwm's own border
+ and titlebar moved freely around it - a second, independent, and
+ steady-state-permanent (not just a momentary post-drag glitch) cause
+ of "decoration doesn't look connected to the window." Fixed by adding
+ the missing branch, calling `x11.configure()` with the current
+ geometry. Unlike the xdg-shell branch (gated on `size_changed`, since
+ xdg-shell position is a purely compositor-side concept never
+ communicated to the client), the X11 branch reconfigures on *every*
+ `sync_geometry` call regardless of whether size changed - an X11
+ client's on-screen position is real window state it has to be told
+ about on every move, the same way a real X11 window manager sends
+ continuous `ConfigureNotify` during an interactive drag.
+ Neither fix is a refactor: both are a handful of lines in one function
+ each, and both had an already-correct reference pattern sitting
+ elsewhere in the same codebase to copy (X11's own `ButtonRelease`
+ handler; XWayland's own initial-map `configure()` call) rather than a
+ new mechanism to invent. The underlying process gap worth noting: there
+ is no invariant (test or type-level) forcing every geometry-mutating
+ call site to route through `sync_geometry` - that is exactly the class
+ of bug that let both of these happen and both survive `manager.rs`'s
+ own unit tests (which verify the snap/placement math in isolation and
+ never cross the boundary into "did the backend actually get told").
+- **`xdg_popup` was entirely unimplemented - not a missing feature so much
+ as a client-hanging bug.** `new_popup` was a bare no-op: no
+ `send_configure`, no tracking, no rendering. Per xdg-shell, a popup's
+ first `wl_surface.commit()` cannot proceed without a prior
+ `xdg_surface.configure`; GTK4's Wayland backend (and most real toolkits)
+ blocks that commit in a synchronous roundtrip waiting for it, so every
+ popup hung its client forever, not intermittently. GTK4 implements both
+ tooltips and `Gtk.Popover` as `xdg_popup` - a peer session's gdb
+ backtrace (blocked in `wl_display_dispatch_queue` under
+ `gtk_widget_show`) traced this to exactly that path, and the AGS shell
+ alone has 74+ tooltip/popover usages, so this was hit constantly, just
+ never attributed (hovering a bar icon is not a memorable action).
+ Fixed: `new_popup` now sets pending geometry from the positioner
+ (`PositionerState::get_geometry()`, unconstrained - see below) and
+ configures; `reposition_request` re-geometries and
+ `send_repositioned`s; `commit()` advances `PopupManager`'s
+ unmapped-to-mapped tracking and prunes dead ones. Rendered as ordinary
+ surface-tree elements (`render_elements_from_surface_tree`, same
+ mechanism a client-set cursor image already used) positioned at their
+ parent toplevel's on-screen location plus `PopupManager`'s tracked
+ offset, added to `custom_elements` alongside the cursor/borders/
+ decorations - popups are never `space.map_element`'d, so without this
+ `render_output`'s automatic per-space-element rendering would never see
+ them even once configured. One thing this does NOT do: geometry is not
+ clamped to the output (`PositionerState::get_unconstrained_geometry`
+ needs a target rect in the parent's surface-local space, a real
+ follow-up); a popup positioned very close to a screen edge may render
+ partly off it. Cosmetic, not a hang.
+ **Follow-up, same investigation**: implicit grab + dismiss-on-outside-
+ click (`grab()`) was left as a no-op believing `PopupManager::grab_popup`
+ needed `CompState`'s `SeatHandler::KeyboardFocus` to implement
+ `WaylandFocus + From<PopupKind>`, which it supposedly didn't. Rechecked
+ while implementing `move_request`/`resize_request` below (same trait,
+ adjacent methods) - it already did: `KeyboardFocus`/`PointerFocus` are
+ both plain `WlSurface`, smithay provides `impl From<PopupKind> for
+ WlSurface` itself, and `WlSurface: From<WlSurface>` trivially. No
+ blocker ever existed by the time this got rechecked; the earlier note
+ just hadn't been revisited. Fixed: `grab()` now calls `grab_popup`,
+ installs the returned `PopupGrab`'s default `PopupKeyboardGrab`/
+ `PopupPointerGrab` on the seat, and lets smithay's own default grab
+ implementations handle the dismiss-on-outside-click behavior (their own
+ documented purpose).
+- **`zwlr_foreign_toplevel_handle_v1.app_id`/`.title` were sent as empty
+ strings on every window, always** - see `PANEL_SUPPORT_TODO.md`'s P1
+ section for the root cause and fix (re-read on every `commit()` rather
+ than once at role-assignment time). Both `Window.app_id`/`.title` and
+ everything downstream of them (`srd.rule({ class = ... })`, the
+ foreign-toplevel protocol) were affected identically, since both read
+ the same fields populated the same way.
+- **Titlebar button icons were drawn one full button-width left of where
+ clicking them actually registered.** `decoration.rs`'s three
+ `draw_*_icon` calls passed `right_offset` values of `height`, `height*2`,
+ `height*3` for close/maximize/minimize; `button_box`'s formula
+ (`right = width - right_offset`) means those land the icons in the
+ *second*, *third*, and *fourth* button-width squares from the right
+ edge, not the first, second, and third squares `ResizeEdge::hit_test` in
+ `crates/core/src/window.rs` actually assigns to Close/Maximize/Minimize.
+ Concretely: the true Close hit-zone (the rightmost `TITLEBAR_HEIGHT`
+ pixels) was visually blank; the drawn "X" icon sat in the square that
+ hit_test treats as Maximize; the drawn square icon sat in the square
+ hit_test treats as Minimize; the drawn minimize line sat past all three
+ button bands, in plain drag territory. Every titlebar button was
+ therefore one click-target to the right of its own icon. Fixed by
+ changing the three offsets to `0`, `height`, `height*2` respectively, so
+ each icon lands in the same square hit_test assigns it. A regression
+ test (`button_icons_are_drawn_in_the_squares_hit_test_assigns_them`)
+ renders a titlebar, finds each icon's drawn square, and asserts
+ `hit_test` at that square's centre reports the matching button --
+ confirmed it fails on the pre-fix offsets and passes on the corrected
+ ones.
+- **A bordered window's titlebar rounded its own top corners while the
+ border frame around it stayed square**, leaving a small gap at exactly
+ those two corners where the rounded cutout exposed whatever was behind
+ the window instead of the border - undermining `border_strips`, whose
+ whole purpose was making the titlebar read as part of the window rather
+ than a strip bolted on top of it (reported live as "bars/decorations
+ don't feel part of the window"). `render_titlebar` now takes a
+ `round_corners: bool`; `redraw_decoration_buffer` passes
+ `w.border_width == 0`, so a bordered window's titlebar stays flush-square
+ with its border (no gap, fully continuous frame) and a borderless window
+ keeps the rounded top corners it had no square frame to clash with.
+- **Window borders never actually rendered on the real (udev) backend --
+ every window looked like a bare titlebar floating over content, with no
+ frame at all.** Confirmed by pixel-sampling a live screenshot: the
+ transition from desktop background straight to the titlebar's own pixels
+ had zero border-coloured pixels in between, not merely a faint or
+ dim border - literally none. Root cause: `border_strips` rendered each
+ strip as a cached 1x1 solid-colour `MemoryRenderBuffer`, stretched to the
+ strip's real size via `MemoryRenderBufferRenderElement::from_buffer`'s
+ `size` override (upscaling a tiny buffer, the same trick the cursor
+ bitmaps use). Smithay's `PixmanRenderer` - the udev backend's software
+ renderer - hardcodes `src_image.set_repeat(Repeat::None)` on every
+ imported texture with no per-call override in 0.7.0. Combined with
+ bilinear upscale filtering, sampling a 1x1 image stretched across a much
+ larger destination has no valid neighbouring texels under `Repeat::None`
+ to blend against, so it rendered fully transparent. Decorations (the
+ titlebar bitmap) never hit this because they pass `size: None` - a
+ real, non-stretched, buffer-native-size bitmap, `scale == 1.0`, so
+ pixman's transform-and-sample path is skipped entirely. The winit (GPU/
+ GLES) backend was never affected: OpenGL's texture sampler returns a 1x1
+ texture's single texel regardless of wrap mode, so the exact same code
+ "worked" there by accident - meaning this bug was invisible in every
+ nested/dev-session test and only ever showed up on real hardware, the
+ one backend that actually matters for daily driving. Fixed by rendering
+ borders as `SolidColorRenderElement` instead (`smithay::backend::renderer::element::solid`,
+ new `Solid` variant on `OverlayElement`) - a native `Frame::draw_solid`
+ fill with no texture import or sampling involved at all, so this backend
+ difference cannot affect it. Also removed the now-dead 1x1-buffer
+ machinery it replaced (`CompState::border_buffers`, `border_buffer_for`,
+ `decoration::solid_pixel`). Not yet re-verified live on the real udev
+ backend - needs a restart.
+- **`zwp_linux_dmabuf_v1`** - no client could ever hand over a GPU buffer;
+ GTK4 in particular tried to open a DRM render node to allocate one
+ anyway, found no dmabuf global to negotiate through, and crashed instead
+ of falling back gracefully, forcing `GSK_RENDERER=cairo` (full software
+ rendering) on every GTK4 client just to survive. Full account, including
+ why this works at all given the udev backend's `PixmanRenderer` has no
+ GPU pipeline, in `docs/PANEL_SUPPORT_TODO.md`'s P0.3. Live-verified in an
+ isolated nested instance that the global is now advertised and a real
+ client connects/maps normally; full GPU-allocated-buffer round trip
+ needs a retest on real hardware, this sandbox has no working DRM render
+ node to allocate one against.
+- **`xdg_activation_v1`** - a launcher's freshly-spawned app had no way to
+ raise itself once its window mapped; it just opened unfocused behind
+ everything. `request_activation` reuses the same `focus_window` path a
+ dock's foreign-toplevel "activate" already goes through. Full account in
+ `docs/PANEL_SUPPORT_TODO.md`'s P1. Live-verified the same way as dmabuf
+ above: the global advertises correctly in an isolated nested instance,
+ a real client connects/maps normally.
+- **The entire compositor is one thread: input dispatch, client protocol
+ dispatch, and rendering all run serially inside `UdevPlatform::poll_events`
+ (`crates/wayland/src/udev.rs`, `~line 1149`) every tick.** Found while
+ investigating a fresh "clicking still doesn't work" report by checking
+ `~/.local/state/wm-session-*.log` for anything libinput itself had to say:
+ it did --
+ ```
+ libinput error: event10 - Logitech USB Optical Mouse: client bug: event
+ processing lagging behind by 831ms, your system is too slow
+ libinput error: client bug: timer button-debounce-...: scheduled expiry
+ is in the past (-216ms), your system is too slow
+ libinput error: WARNING: log rate limit exceeded (5 msgs per 3600000ms).
+ Discarding future messages.
+ ```
+ i.e. libinput's own watchdog saw the compositor fail to call back into it
+ for most of a second, on the *mouse and keyboard event sources both* --
+ and then it rate-limited itself to 5 messages/hour, so the *absence* of
+ further such lines in the log is not evidence this stopped happening,
+ only that libinput stopped reporting it. Correlated with timestamps of
+ `libEGL warning: failed to get driver name` and
+ `[WARN audioipc2_server::server] Promotion of content process thread to
+ real-time` right alongside it - `audioipc2` is Firefox's own audio IPC
+ subsystem, meaning this specific incident lines up with Firefox's
+ multi-process startup (GPU/audio/content processes all spawning at once)
+ putting the whole system under enough transient CPU/memory pressure that
+ even the compositor's own event loop missed its scheduling window --
+ consistent with this being a genuinely resource-constrained machine (see
+ the earlier-documented 1.3GB-swap finding). A direct IPC round-trip check
+ (`srd clients`, timed) immediately after finding this came back at a
+ steady 10-20ms, so the loop is not *currently* stalling - this specific
+ 831ms incident is not, by itself, an explanation for an ongoing "clicking
+ doesn't work" complaint reported well after it happened. It is still a
+ real, reproducible architectural weak point: because `poll_events` calls
+ `event_loop.dispatch(16ms timeout)` *then* runs `render_udev_frame()`
+ synchronously before looping back, any single slow render pass (a burst
+ of new windows/layer-surfaces all redrawing at once, say) directly adds
+ to input latency, with nothing to prioritize input processing over
+ rendering when both are contending for the same thread. Not fixed --
+ the real fix (moving libinput's event source dispatch to its own thread,
+ independent of the render cadence) is a genuine, non-trivial
+ restructuring of the event loop, not a targeted patch, and is exactly
+ the kind of thing worth scoping as a deliberate follow-up rather than
+ rushing into the same pass that found it.
+- **`toggle_fullscreen`'s exit path hardcoded `Window.decorated = true`
+ unconditionally - the actual root cause behind persistent "clicking
+ doesn't work" and "decoration looks detached" reports on Firefox, found
+ by adding temporary diagnostic logging to the click-routing path and
+ correlating a live "clicked the back button, it minimized the window
+ instead" report against it.** The log showed `hit_test` returning
+ `Some((firefox_id, TitlebarHit::Drag))` for a click on Firefox's own
+ toolbar - impossible if `Window.decorated` were actually `false`, which
+ is what `srd.rule({ class = "firefox" }, { decorated = false })` sets it
+ to (Firefox draws its own close/minimize/maximize row and negotiates
+ client-side decoration once, at startup, via `zxdg_toplevel_decoration_v1`
+ - see the `XdgDecorationHandler` doc comment). Root cause: entering
+ fullscreen correctly saved nothing and set `decorated = false`
+ (fullscreen has no titlebar, always), but *exiting* fullscreen hardcoded
+ it back to `true` regardless of what it was before entering - correct
+ for a normally-decorated window, wrong for anything a rule set to
+ `false`. Since Firefox never renegotiates its decoration mode again
+ after the initial handshake, nothing would ever set it back. `Super+f`/
+ `Super+m`/`Super+z` are all bound to `srd.window.fullscreen()` in the
+ shipped config - an easy, plausible accidental press during any long
+ session, not a rare edge case. Once `decorated` was wrongly `true`,
+ every click in what srdwm now (incorrectly) treated as the top
+ `TITLEBAR_HEIGHT` band of Firefox's window got swallowed as a fake
+ drag/button hit instead of ever reaching the client - matching both
+ "clicking doesn't work" (band clicks silently absorbed) and "I have to
+ click somewhere else for it to register" (only clicks *below* the band
+ ever reached Firefox) exactly. Fixed with a new `Window.restore_decorated:
+ Option<bool>` field, following the exact same save/restore pattern
+ `restore_geometry` already uses for `toggle_maximize`/`toggle_fullscreen`'s
+ geometry: entering fullscreen now saves the current `decorated` value
+ before forcing `false`, exiting restores it instead of hardcoding `true`.
+ Regression test:
+ `fullscreen_round_trip_restores_a_client_side_decorated_window_to_undecorated`.
+ Diagnostic logging (temporary, since removed - see below) confirmed no
+ client-side keyboard double-dispatch at the point srdwm receives events
+ from libinput (every key logged shows a clean, single Pressed/Released
+ pair) - the separately-reported "typing feels sensitive, letters
+ double" bug is not the same mechanism as this one. It was tracked down
+ later in this same pass to a too-short repeat delay, not server-side
+ duplication; see the `REPEAT_DELAY`/`add_keyboard` entry further down.
+- **`maximize_request`/`unmaximize_request`/`fullscreen_request`/
+ `unfullscreen_request`/`minimize_request` were all still smithay's
+ default no-op (or configure-only) implementations - found immediately
+ after the `toggle_fullscreen` fix above, checking what else in
+ `XdgShellHandler` might share its blast radius.** These are the
+ *client-initiated* equivalent of the pointer-driven titlebar-button
+ handlers already in `input.rs`: a client's own window-menu "Maximize",
+ pressing F11, an HTML5 video going fullscreen, or (for a client that
+ negotiated client-side decoration and draws its own titlebar, like
+ Firefox) that titlebar's own maximize button - all ask the compositor
+ to actually perform the state change via these requests rather than the
+ compositor noticing on its own. Left unimplemented, every one of them
+ was a silent no-op, indistinguishable from a client bug: the button
+ visibly existed and could be clicked, nothing happened, no error. Fixed
+ by wiring all five to the same `WindowManager` calls (`toggle_maximize`,
+ `toggle_fullscreen`, `minimize_window`) the pointer path already uses,
+ including the same `redraw_decoration_buffer`-before-`sync_geometry`
+ ordering `set_decorated_from_mode` established for anything that flips
+ `Window.decorated` (fullscreen does; maximize/minimize don't).
+- **`move_request`/`resize_request` were *also* still smithay's default
+ no-op implementations - the single biggest gap found in this whole
+ investigation.** This is how a client-side-decorated window gets dragged
+ or resized by its own titlebar/edges at all. A window srdwm draws its
+ own decoration for never needed this (`TitlebarHit::Drag`/`Resize` in
+ `input.rs` detect the click directly, since srdwm owns those pixels),
+ but a window that negotiated client-side decoration and draws its own
+ titlebar - Firefox, and most GTK4 apps by default - handles the click
+ itself and then asks the compositor to actually perform the move/resize
+ via exactly these two requests. Left unimplemented, dragging or
+ resizing *any* such window by its own chrome did nothing at all - the
+ only way to reposition one was the modifier+drag-anywhere gesture
+ (`bindm`), which most users have no reason to know exists and doesn't
+ cover resize-from-a-specific-edge at all. Fixed by reusing the exact
+ same `WindowManager::start_drag`/`start_resize` the pointer-driven
+ titlebar handlers call: `handle_pointer_position`/`handle_pointer_button`
+ already drive any in-progress drag/resize to completion on subsequent
+ motion/release regardless of what started it, so no smithay pointer
+ grab was needed here at all, just the same start call from a different
+ trigger. `xdg_toplevel::ResizeEdge`'s 8 real edge values map directly
+ onto `srdwm_core::ResizeEdge`; the protocol's `None` value is a no-op
+ (nothing sensible to default it to that wouldn't be a guess).
+- **`PopupManager::grab_popup` was believed blocked on `CompState`'s
+ `SeatHandler` associated types not satisfying its `WaylandFocus +
+ From<PopupKind>` bound, and `grab()` was left a no-op on that basis --
+ rechecked while implementing `move_request`/`resize_request` above
+ (same trait, adjacent methods) and the bound was already met**:
+ `KeyboardFocus`/`PointerFocus` are both plain `WlSurface`, smithay
+ itself provides `impl From<PopupKind> for WlSurface`, and
+ `WlSurface: From<WlSurface>` trivially. No blocker ever existed by the
+ time of this pass; the earlier note just hadn't been revisited since it
+ was written. Fixed: `grab()` now calls `grab_popup`, installs the
+ returned `PopupGrab`'s default `PopupKeyboardGrab`/`PopupPointerGrab` on
+ the seat, and lets smithay's own default grab implementations handle
+ dismiss-on-outside-click (their documented purpose) - a popup (tooltip,
+ dropdown, context menu) now closes when you click outside it, instead
+ of only when its own client decides to close it.
+- **`popup_targets` (the render path a mapped `xdg_popup` needs to find
+ its on-screen position) only ever scanned toplevel windows for a popup
+ parent, never layer-shell surfaces - so any popup parented to a bar or
+ launcher's own layer surface was fully functional and completely
+ invisible.** `zwlr_layer_surface_v1.get_popup` (what a bar's own
+ dropdown/context menu uses to parent a popup to itself) is a wholly
+ separate request from `xdg_surface.get_popup`, but both funnel into the
+ same `XdgShellHandler::new_popup`/`commit()` tracking path regardless of
+ which kind of surface ends up as the parent - so the popup was always
+ correctly configured and tracked (a bar's dropdown would open and
+ accept clicks), it just never had a `PopupTarget` to render relative to.
+ Fixed by also gathering every mapped layer-shell surface, across every
+ output, as a candidate popup parent alongside toplevel windows.
+- **The same six no-op requests found missing from `XdgShellHandler`
+ (`maximize_request`/`unmaximize_request`/`fullscreen_request`/
+ `unfullscreen_request`/`minimize_request`, plus `unminimize_request`,
+ which has no native-Wayland equivalent) were equally unimplemented for
+ XWayland's `XwmHandler` - the EWMH/ICCCM equivalent
+ (`_NET_WM_STATE_MAXIMIZED_VERT`/`_HORZ`, `_NET_WM_STATE_FULLSCREEN`,
+ `_NET_WM_STATE_HIDDEN`).** `move_request`/`resize_request` were
+ *already* correctly implemented for XWayland (`crates/wayland/src/
+ xwayland.rs`), which is what made the state-toggle half of this gap easy
+ to miss - the drag/resize half already had parity with native Wayland;
+ the maximize/fullscreen/minimize half didn't. Any XWayland app's own
+ window-menu maximize/minimize/fullscreen action (older GTK3/Qt5/Java
+ apps, anything not forced into native-Wayland mode) was a silent no-op.
+ Fixed the same way as the native-Wayland five: wired to the same
+ `WindowManager` calls, with the same `redraw_decoration_buffer`-before-
+ `sync_geometry` ordering for fullscreen specifically (it flips
+ `Window.decorated` for an XWayland window's `Window` entry exactly the
+ same way it does for a native one).
+- **`zwlr_foreign_toplevel_management_v1` wasn't re-broadcasting
+ maximize/fullscreen/minimize state changes that didn't originate from
+ the protocol itself.** `foreign_toplevel::send_state` was already called
+ from `set_maximized`/`set_minimized`/`announce`/`update_activated`, but
+ never from the pointer-driven titlebar handlers in `input.rs`
+ (`TitlebarHit::Maximize`/`Minimize`, and double-click-to-maximize on
+ `TitlebarHit::Drag`), nor from any of the client-initiated
+ `XdgShellHandler`/`XwmHandler` requests just fixed above - so a dock or
+ panel's own maximized/minimized indicator went stale the moment a user
+ clicked a titlebar button directly, or a client asked for the state
+ change itself, instead of driving it through the dock. Fixed by adding
+ the same `send_state` call to all eleven sites (3 in `input.rs`, 5 in
+ `protocols.rs`, 6 in `xwayland.rs` - `unminimize_request` included,
+ even though it calls `restore_window` rather than `minimize_window`).
+ One trigger remains unfixed: a *compositor keybinding*
+ (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`) still doesn't
+ re-broadcast, because `crates/config` (the Lua engine, shared with the
+ X11/macOS backends) only ever holds a `WindowManager` reference, never
+ `CompState` - it has no way to reach a Wayland-protocol-specific
+ function that lives one layer up, in the crate that depends on it. See
+ `docs/PANEL_SUPPORT_TODO.md`'s P1 section for the full note; closing it
+ needs a small design decision (a dirty-window callback, or a periodic
+ diff/broadcast pass in `CompState`'s own tick), not a one-line patch.
+- **`zwlr_foreign_toplevel_handle_v1.set_fullscreen`/`.unset_fullscreen`
+ were silent no-ops**, and `Fullscreen` was missing from the state bytes
+ the handle ever sent - so a dock had no way to ask a window to
+ fullscreen, and couldn't tell if one already was, even though
+ `toggle_fullscreen` was exactly as reachable from `foreign_toplevel.rs`
+ as `toggle_maximize` already was (both are plain `WindowManager`
+ methods). Left over from before `move_request`/`resize_request` and the
+ five `XdgShellHandler` fullscreen/maximize/minimize requests were fixed
+ earlier in this pass - at the time this module was originally written,
+ the doc comment's given reason ("no `srd.window.fullscreen()`-equivalent
+ entry point reachable from here") was accurate; it stopped being true
+ once those fixes landed, and nothing had gone back to revisit it. Fixed
+ by adding `set_fullscreen`, mirroring `set_maximized`'s shape (including
+ the same `redraw_decoration_buffer`-before-`sync_geometry` ordering
+ fullscreen always needs), and adding `State::Fullscreen` to
+ `state_flags`/`send_state_to`. The request's `output` argument is
+ ignored as a hint only, matching `fullscreen_request`'s `_output` in
+ `protocols.rs`.
+- **`update_cursor_shape` left the resize cursor stuck once the pointer
+ moved from one of srdwm's own decoration edges straight onto the bare
+ desktop.** Its early-return for "over a client surface, let the client
+ drive its own cursor" didn't distinguish that case from "over nothing at
+ all" - on the empty desktop there is no client to ever call
+ `set_cursor` and reset it, so whatever named icon was showing (a resize
+ arrow, most noticeably) stayed forever until the pointer happened to
+ cross back onto some client's content. Fixed by threading through
+ whether any window is actually under the pointer (`over_content`,
+ computed once in `handle_pointer_position` from the same `under` lookup
+ already used for click routing) and falling back to `CursorIcon::Default`
+ instead of returning early when it's `false`.
+- **`cursor.rs` only had dedicated art for two shapes (text entry, the four
+ resize directions) - every other named shape a client can request,
+ including `CursorIcon::Pointer` (the hand shown over every hyperlink and
+ most other clickable non-form controls, by far the most common named
+ shape after the plain arrow), silently fell back to the same arrow shown
+ everywhere else.** Added three more built-in bitmaps following the same
+ solid-black-plus-white-halo style the resize/text shapes already use:
+ `crosshair_bitmap` (a plain centered `+`), `move_bitmap` (a four-way
+ arrow, built the same way `diagonal_resize_bitmap`'s arrowheads are, just
+ aimed at all four cardinal directions), and `pointer_bitmap` (a blocky
+ pointing hand - an upright finger over a wider palm block, hotspot at
+ the fingertip via a new `POINTER_HOTSPOT` constant rather than the
+ centered hotspot every other non-arrow shape uses). Everything else
+ (grab, wait, help, and the dozen or so rarer named shapes) still falls
+ back to the arrow - deliberately scoped to the shapes common enough to
+ be immediately noticeable when wrong, same reasoning the module's own doc
+ comment already gives for why it isn't a full XCursor theme.
+- **`ext_workspace_manager_v1` (`crates/wayland/src/workspace.rs`) turned
+ out to already be fully implemented and wired into both backends'
+ globals - `docs/PANEL_SUPPORT_TODO.md`'s P1 list had it marked as not
+ done, which was simply stale.** Auditing every `WindowManager::
+ switch_workspace` call site (the same technique that found `foreign_
+ toplevel`'s broadcast gap) turned up a real, smaller gap instead: the
+ `SUPER+scroll` workspace-cycle gesture in `input.rs` called
+ `switch_workspace` directly without calling `workspace::
+ broadcast_active_workspace` afterward, so a dock's workspace pill only
+ ever tracked switches driven through this protocol's own `activate`
+ request - identical shape to the maximize/fullscreen/minimize gap fixed
+ above, just for workspaces instead of window state. Fixed by making
+ `broadcast_active_workspace` `pub(crate)` and calling it from the scroll
+ gesture. The `srd.workspace.next()`/`.prev()`/`.switch()` Lua API has the
+ identical gap for the identical reason (`crates/config` only ever holds a
+ `WindowManager` reference, never `CompState`) and remains open, same as
+ the Lua-keybinding gap already noted for maximize/fullscreen/minimize.
+- **The keyboard repeat delay was hardcoded to 200ms - likely the real
+ explanation behind the separately-reported "typing feels sensitive,
+ letters double" symptom, which the earlier investigation above confirmed
+ was not server-side event duplication.** Found by comparing against
+ Hyprland's default (`repeat_delay = 600`), after a live report that
+ typing felt noticeably more sensitive under srdwm than under other
+ compositors on the same hardware - the machine already had a Hyprland
+ config on disk (`~/.config/hypr/hyprland.conf`) confirming it had
+ actually been used there before, on this same keyboard, without that
+ complaint. `repeat_info` is sent to a client once and the client manages
+ its own repeat timer entirely on its own from then on (confirmed reading
+ `smithay`'s `wayland/seat/keyboard.rs`: `repeat_info` is only ever sent
+ on bind and via `change_repeat_info`, never re-driven per keystroke) --
+ so a 200ms delay means any key held even slightly past a fifth of a
+ second, which is well within normal variance in how long a real
+ keystroke's finger-down/finger-up dwell actually is, starts the client's
+ own repeat and inserts an unintended extra character. This is
+ indistinguishable from "double-typing" to the person typing, entirely
+ client-side, and was invisible to the earlier diagnostic logging (which
+ only watched the compositor's own reception/dispatch, both of which were
+ and remain genuinely clean). Changed both backends' `add_keyboard(..,
+ 200, 25)` to `600, 25`, and `state.rs`'s `REPEAT_DELAY` constant (used
+ for the compositor's own held-keybinding repeat, kept in sync with the
+ seat's setting by design) to match. The double-typing bug is no longer
+ listed as unexplained - this is the leading fix for it, though not yet
+ live-verified against a real typing session (see the "don't restart
+ without permission" note elsewhere in this doc/session).
+- **`xdg_toplevel`'s `Activated` state was never sent to any client at
+ all - found investigating a live report that a single open window,
+ with nothing else it could possibly be losing focus to, still didn't
+ look focused.** `set_keyboard_focus` (the one chokepoint every focus
+ change already goes through) only ever called `keyboard.set_focus`,
+ which delivers `wl_keyboard.enter`/`leave` - real input focus - but
+ says nothing about `xdg_toplevel` state. The only place `Activated` was
+ ever touched was `foreign_toplevel::update_activated`, which is a
+ completely different, dock-facing protocol
+ (`zwlr_foreign_toplevel_handle_v1`), not the client's own window. GTK4/
+ libadwaita's `:backdrop` CSS pseudo-class - and most other toolkits'
+ equivalent - keys off exactly the state that was never sent, so any
+ client that draws its own focus indicator this way (a differently
+ colored/styled titlebar when "unfocused") looked permanently unfocused
+ regardless of real keyboard focus or window count: a lone window is
+ never *not* the focused one, so this was actually the easiest case to
+ notice it in, not a coincidence. Fixed with `set_window_activated`, a
+ new helper next to `set_keyboard_focus` that calls `smithay::desktop::
+ Window::set_activated` (which already unifies the xdg-shell and X11
+ cases - sets the pending state for the former, talks straight to the X
+ connection for the latter) for both the window losing focus and the one
+ gaining it, sending a `send_configure` for the native-Wayland case since
+ `set_activated` alone only queues the pending state.
+
+- **Every `class`-based `srd.rule` silently never matched a native Wayland
+ window - the actual root cause of Firefox's persistent double titlebar,
+ and (same mechanism) of every other rule in the shipped `rules.lua`
+ quietly not applying to native-Wayland apps (floating for `mpv`/`vlc`/
+ `pavucontrol`, workspace assignment for `discord`/`Spotify`, etc.).
+ Found by nesting srdwm under the live session with an isolated
+ `XDG_RUNTIME_DIR` (symlinking only the host socket in, under a name
+ that doesn't collide with the guest's own auto-picked server socket --
+ reusing the same name once caused the *next* nested launch to fail to
+ connect out, since the guest's own `bind_auto` had overwritten the
+ symlink) and screenshotting a real Firefox window via `grim`.**
+ `WindowManager::add_window` matches rules once, against whatever
+ `title`/`app_id` the `Window` already has - but `new_managed_window`
+ populates those from `XdgToplevelSurfaceData` at `get_toplevel` role-
+ assignment time, which is *before* essentially every real client's
+ `set_title`/`set_app_id`/first commit, not racily but every single
+ time. So `add_window` always evaluated rules against an empty
+ `app_id`, and `srd.rule({ class = "firefox" }, { decorated = false })`
+ - meant to stop srdwm drawing a second titlebar over Firefox's own --
+ could never match. `sync_toplevel_metadata` already re-read the real
+ `app_id`/`title` on every commit (for the foreign-toplevel broadcast),
+ but never gave rules a second chance. Fixed with `Window::
+ rules_applied` (sticky once real identity has been evaluated, so a
+ later unrelated title change - a browser tab switching - can't
+ re-match and re-apply) and `WindowManager::reapply_rules_if_pending`,
+ called from `sync_toplevel_metadata` once `title`/`app_id` actually
+ change *and a rule actually matched* (see the next entry for why that
+ second condition matters), followed by the same `redraw_decoration_
+ buffer`-then-`sync_geometry` ordering `set_decorated_from_mode` uses for
+ any `decorated` flip. XWayland was never affected: `map_window_request`
+ (`xwayland.rs`) sets `app_id` from `X11Surface::class()` synchronously,
+ before `add_window` runs. Regression test: `class_rule_applies_once_
+ app_id_is_known_after_creation`. Screenshot-verified end to end in the
+ isolated nested instance: Firefox's own CSD tab strip is now the only
+ chrome, with no srdwm-drawn band above it.
+- **The nested (winit) backend's own debug window was titled "Smithay"**
+ - the hardcoded default `smithay::backend::winit::init()` uses
+ internally, surfaced while investigating the above and reasonably
+ read as "this compositor is just smithay with no work of its own on
+ top" rather than the dev-window cosmetic default it actually is.
+ Switched to `winit::init_from_attributes` with `.with_title("srdwm")`,
+ the only behavioral difference from `init()`.
+- **`reapply_rules_if_pending` calling `sync_geometry` unconditionally on
+ every title/app_id change (not just the first, rule-matching one) was a
+ real bug in its own right, independent of the fix above.**
+ `sync_geometry` calls `Space::map_element`, which - per smithay 0.7.0's
+ own source - always re-stacks its target to the top on every call
+ (remove, push to the end, stable-sort by `z_index`; every window shares
+ the same default `z_index`, and `activate: bool` only toggles the
+ `xdg_toplevel` activated *state*, it does not gate the restack). A
+ window's title changing is not a user action and has nothing to do with
+ raising it - but it happens constantly for perfectly ordinary reasons
+ (a browser tab finishing a page load) long after the window's own
+ creation. Fixed by having `reapply_rules_if_pending` return whether a
+ rule actually matched (`Some`, not just "already evaluated"), and only
+ calling `redraw_decoration_buffer`/`sync_geometry` from
+ `sync_toplevel_metadata` when it did - the overwhelmingly common case
+ (no matching rule) now correctly does neither.
+- **FIXED (a later pass, same session) - the window-stacking bug above:
+ two overlapping native Wayland toplevels compositing in the wrong
+ front-to-back order was real, reproducible, and root-caused by
+ instrumenting a locally vendored copy of smithay directly (`vendor/`
+ + `[patch.crates-io]`, both removed once done - see the commit/diff
+ history if the technique is needed again).** `eprintln!`s in
+ `OutputDamageTracker`'s `damage_output_internal` (the element-collect
+ loop) and its draw loop, plus one in `sync_geometry`, proved the actual
+ mechanism: `smithay::desktop::space::Space::map_element` - 0.7.0's only
+ way to update an element's tracked position - *always* re-stacks its
+ target to the top of `Space`'s internal order as a side effect, `activate`
+ argument or not (there is no "move without restacking" in this smithay
+ version). `sync_geometry` calls `map_element` for reasons that have
+ nothing to do with raising a window: a title/app_id changing, an
+ ordinary resize frame, anything that touches position or size. Two
+ windows created moments apart each independently go through their own
+ startup title/app_id negotiation, each triggering a handful of
+ `sync_geometry` calls purely from that - so whichever one's startup
+ sequence happened to settle *last* silently won `Space`'s notion of "on
+ top", a race with no relationship to which window `WindowManager` (or
+ the user) actually considered focused. This is why it looked like
+ "whichever was created first always wins" in early testing and why every
+ srdwm-side rendering variable (push order, forced full redraws, buffer
+ age) made no difference - none of them touched the actual mechanism.
+ Fixed with `CompState::resync_stacking_order`, called right after every
+ `map_element` in `sync_geometry`: re-applies `WindowManager.order`
+ (already the single source of truth for stacking, and already what
+ `hit_test`/`window_at`/rendering itself use) to `Space` via
+ `raise_element` in bottom-to-top sequence, so the two can never drift
+ apart again regardless of why `sync_geometry` was called. Verified live,
+ repeatedly, in an isolated nested instance: two overlapping `wezterm`
+ windows with no rules involved, staggered by both 5s and 2s to stress
+ the startup-timing race, correctly show the focused one on top every
+ time; `srd dispatch focus <id>` on the backgrounded one correctly brings
+ its content to the front, confirming this tracks real focus changes and
+ isn't just "newest window wins" by coincidence.
+
+## `ext_idle_notify_v1` / `zwp_idle_inhibit_manager_v1` (added this pass)
+
+Both use smithay's own complete, built-in modules (`wayland::idle_notify`/
+`idle_inhibit`) - unlike everything else hand-written in this crate against
+raw protocol bindings, neither needed that: smithay already ships full
+working server-side implementations of both, timers included.
+
+- `IdleNotifierHandler`/`IdleInhibitHandler` implemented for `CompState`
+ (`protocols.rs`); `input::notify_idle_activity` calls `IdleNotifierState::
+ notify_activity` from all four real input paths (`handle_pointer_position`,
+ `handle_pointer_button`, `handle_keyboard_key_event`,
+ `handle_workspace_scroll`, which every `PointerAxis` event already routes
+ through) - deliberately including while the session is locked, since idle
+ activity is about the seat, not about which surface an event reaches.
+ Throttled to once per 250ms: `notify_activity` removes and re-inserts a
+ calloop timer per live notification on every call with no throttling of
+ its own, and idle timeouts are measured in minutes, so nothing needs
+ finer resolution than that - the same class of hot-path-per-motion-event
+ cost this session's earlier diagnostic-logging regression already proved
+ worth being careful around, just cheap enough (in-memory bookkeeping, not
+ synchronous I/O) that throttling rather than omitting it was the right
+ call.
+- `IdleInhibitHandler::inhibit`/`uninhibit` track inhibiting surfaces in a
+ new `CompState::idle_inhibiting_surfaces` list and call `IdleNotifierState::
+ set_is_inhibited` accordingly. Deliberately not workspace-visibility-aware
+ (an inhibiting window on a workspace you've switched away from still
+ keeps the system awake) - see that field's own doc comment for the
+ reasoning. `remove_window` also clears a window's entry as a safety net:
+ smithay's own `IdleInhibitorState` only calls `uninhibit` on an explicit
+ `destroy` request, never on ungraceful client death, so a crashed video
+ player would otherwise hold the whole system awake forever with no
+ client left to ever release it.
+- **The winit (nested/dev) backend genuinely has no `calloop` event loop of
+ its own at all** (documented in `ipc.rs`'s own module doc comment,
+ predating this work) - but `IdleNotifierState::new` requires a real
+ `LoopHandle` to register its per-notification timers against, and its
+ handler trait must return a real, working state unconditionally (no
+ `Option` in the signature), so this couldn't be skipped for that backend
+ without either breaking the shared `CompState` type both backends use or
+ advertising a global whose events would then simply never fire - worse
+ than not having the protocol at all. Gave `WaylandPlatform` (winit.rs) a
+ second, narrowly-scoped `calloop::EventLoop<'static, CompState>` used
+ for nothing except hosting these timers, dispatched non-blocking
+ (`Duration::ZERO`) once per `poll_events` tick alongside the existing
+ per-tick work. The udev backend already had a real event loop for this
+ to use directly, no new plumbing needed there.
+
+## Placement, dragging, fullscreen-vs-dock, decoration polish (added this pass)
+
+- **`Monitor` conflated two genuinely different rectangles into one
+ `geometry` field: the exclusive-zone-shrunk usable area (what
+ placement/tiling/maximize should respect) and the output's true full
+ rect (what fullscreen - and a window being interactively dragged --
+ should be able to reach or cross).** Reported as two symptoms of the
+ same cause: fullscreen (`Super+z`) stopped short of a dock's reserved
+ strip instead of covering it like every other compositor's fullscreen
+ does, and a floating window being dragged could not be moved into that
+ strip at all - not merely discouraged, physically unreachable at any
+ drag speed or angle, since `update_drag`'s clamp used the same shrunk
+ `geometry`. Fixed by adding `Monitor::full_geometry` (defaults to
+ `geometry` for any backend not yet taught the distinction), populated
+ in `udev.rs`/`winit.rs`'s `monitors()` from the output's real mode size
+ rather than `non_exclusive_zone()`. `toggle_fullscreen` and
+ `WindowManager::update_drag`'s clamp now use `full_geometry`;
+ `toggle_maximize`, `SmartPlacement`'s grid/cascade, and `snap_zone`'s
+ top-edge-maximize case deliberately still use `geometry` - a *new*
+ window's placement and a top-edge snap are both "maximize", which
+ should keep avoiding the dock the same as before. Regression tests:
+ `fullscreen_covers_the_full_monitor_ignoring_a_dock_reservation`,
+ `maximize_still_respects_the_dock_reservation`,
+ `dragging_a_window_can_cross_into_the_dock_reserved_strip`.
+- **`PlacementConfig::snap_threshold` (edge-magnetism distance for
+ Windows-Snap-style drag-to-edge) was still reported as too sensitive
+ at 20px, the value an earlier pass already reduced it to from 50.**
+ Compounded by the `full_geometry` fix above: before that fix, a
+ dragged window could never actually reach the true screen edge behind
+ a dock, so it could get within the old 20px threshold of `snap_zone`'s
+ comparison edge well before the cursor was anywhere near a real edge.
+ Reduced to 8px - tight enough to require deliberately reaching the
+ edge, not just moving generally toward it. `drag_ending_near_edge_
+ snaps_to_half_screen` updated to match (drags to a point 8px inside
+ the edge instead of 20px).
+- **Every bordered window (the default - `border_width: 2` unless a
+ rule zeroes it) rendered with square corners, while only the rare
+ borderless window got the rounded titlebar treatment** - reported as
+ "not all window borders are rounded," which is more precisely "almost
+ none are." `render_titlebar`'s `round_corners` was deliberately `false`
+ whenever bordered, specifically to avoid a titlebar rounding its own
+ top corners while the square border frame around it didn't (a visible
+ gap at exactly those two corners). Fixed the mismatch from the other
+ side instead of disabling rounding: new `decoration::render_border_top`
+ renders the border's top strip as its own small rounded-corner bitmap
+ (reusing `round_top_corners`, radius `CORNER_RADIUS + border_width` so
+ the cut continues outward from the titlebar's), wired into both
+ backends' render loops in place of a plain `SolidColorRenderElement`
+ for that one strip - the other three (bottom/left/right) don't touch a
+ visible corner and stay solid fills. `redraw_decoration_buffer` now
+ always passes `round_corners = true`. Regression test:
+ `border_top_rounds_its_own_top_corners_to_match_the_titlebar`.
+- **`srd clients`' IPC response carried no geometry** - raised by AGS
+ (the panel this project's `PANEL_SUPPORT_TODO.md`/AGS's own
+ `BACKLOG.md` were written against): its Overview/window-switcher needs
+ window rectangles to lay out miniatures to scale, and neither
+ `zwlr_foreign_toplevel_management_v1` nor `ext_foreign_toplevel_list_v1`
+ carries geometry at all, by design of those protocols - this
+ compositor's own IPC was the only place it could come from. Added
+ `x`/`y`/`width`/`height` (the same global logical-pixel space
+ everything else here uses) to `ClientInfo` in `ipc.rs`.
+- **FIXED (a later pass, same session): the window z-order bug noted
+ here originally as unfixed.** See the entry directly above the
+ idle-notify section for the full writeup - root cause was `Space::
+ map_element` silently re-stacking on every `sync_geometry` call,
+ fixed with `CompState::resync_stacking_order`.
+
+## Input-accuracy pass: cursor shape, scroll (added a later pass, same session)
+
+- **The built-in arrow cursor's tail forked into two legs of visibly
+ different widths - one tapering to a point like the rest of the
+ shape, the other a constant-width block that never tapered, ending in
+ an abrupt flat stop.** Looked fine glanced at in a full screenshot; the
+ asymmetry only became obvious rendering the bitmap in isolation at a
+ large scale, which is what actually caught it, after being told directly
+ that a screenshot glance wasn't good enough - correctly. Redesigned as
+ a single triangular foot mirroring the head's own taper. Regression
+ test: `arrow_tail_is_a_single_tapering_shape_not_a_lopsided_fork`,
+ which asserts every tail row is one contiguous opaque run with
+ non-growing width - it fails against the old bitmap.
+- **Scrolling forwarded to clients was missing two things `PointerAxisEvent`
+ actually provides, one of them a real protocol requirement, not a nicety.**
+ `AxisSource::Finger` (a touchpad) - per `AxisFrame::source`'s own doc
+ comment - *requires* a `stop()` event on the frame where the axis
+ genuinely has no more motion (`event.amount(axis)` returns `None`);
+ nothing ever sent one, on either backend. A client has no reliable way to
+ know a two-finger scroll gesture ended without it, which matters for
+ kinetic/momentum scrolling and for not leaving a gesture "stuck" as far
+ as the client's concerned right before the next one starts - exactly
+ the class of thing that reads as "scrolling doesn't really work" rather
+ than "no events arrive" (discrete wheel scrolling, needing no stop event,
+ was never affected). Also added `amount_v120` (discrete wheel steps,
+ `AxisFrame::v120`) alongside the existing pixel `value()` - optional per
+ protocol, but some clients use it to tell a physical wheel click from
+ smooth scrolling. Both fixed in `udev.rs`'s `PointerAxis` handler.
+- **The nested (winit/dev) backend had no scroll handling at all --
+ `InputEvent::PointerAxis` fell into a catch-all and was silently
+ dropped, unconditionally, regardless of device.** Only really affects
+ development/nested testing (the real session runs on `udev.rs`, already
+ fixed above), but worth closing since it's exactly the kind of gap that
+ would have made this specific bug class impossible to verify by testing
+ nested in the first place. Added the same forwarding (stop/v120 included)
+ as udev.rs's fix.
+- **Window borders (`decoration::border_strips`) are drawn `border_width`
+ pixels outside a window's `geometry`, but hit-testing only ever checked
+ `geometry` itself** - so the visible border was a dead zone: hovering it
+ showed no resize cursor and it couldn't be grabbed, even though it's what
+ visually reads as the window's actual edge. `ResizeEdge::hit_test` now
+ takes `border_width` and widens the containment check by that much on
+ every side; `resize_edge_at`'s own margin comparisons needed no matching
+ change, since they already treat anything at or outside `frame`'s edge as
+ maximally "near". Regression test:
+ `border_pixels_are_hoverable_not_a_dead_zone`.
+- **New feature: `zwlr_output_power_management_v1`** (DPMS on/off per
+ output) - `crates/wayland/src/output_power.rs`, hand-written against
+ `wayland-protocols-wlr`'s raw bindings (no smithay helper exists, same
+ pattern as `screencopy.rs`). udev/DRM backend only: there's no real
+ display to power down when nested under a host compositor, so the global
+ is genuinely not created there (`CompState::_output_power_state` is
+ `Option`, `None` for `winit.rs`) rather than advertised-and-always-
+ failing. `CompState::set_output_power` finds the connector's generic KMS
+ "DPMS" property by name (`drm-rs` has no dedicated call for this, only
+ the same `get_properties`/`set_property` every connector property goes
+ through) and sets it via the raw `DRM_MODE_DPMS_ON`/`_OFF` UAPI values
+ (hardcoded rather than pulling in `drm-sys` for two constants that have
+ been stable since DPMS was added to the KMS UAPI). Complements, and is
+ deliberately independent of, `ext_idle_notify_v1`: that protocol only
+ tells a client the seat went idle, it has no way to blank a screen
+ itself - a real "screen off after N minutes idle" feature needs an idle
+ daemon watching the former and calling `set_mode` on this. Built and
+ tested; **not yet live-verified against real DRM hardware** - this
+ session's nested-instance testing setup stopped being able to nest under
+ the live host partway through this pass (a `winit`-backend "Failed to
+ initialize an event loop" that reproduces identically against the
+ already-installed, pre-existing binary too, so it's an environment
+ change - the live session switched host compositors mid-session - not
+ a regression from this work), so this needs a real restart to confirm
+ the DPMS property is actually found and set correctly on real hardware.
+- **New feature: `zwlr_gamma_control_manager_v1`** (per-output gamma ramp --
+ night light / `gammastep`/`wlsunset`) - `crates/wayland/src/
+ gamma_control.rs`, same hand-written-against-raw-bindings pattern and
+ same udev-only/`Option` reasoning as `output_power.rs` right above.
+ `gamma_size` (sent when a client creates a control object) and the
+ actual ramp length both come from the CRTC's own `gamma_length` via
+ `drm-rs`'s `get_crtc`. The interesting part is `set_gamma`: the
+ protocol hands the table over as a **memory-mapped fd**, not a value on
+ the wire (`size` `u16`s per channel, red/green/blue back to back) --
+ `memmap2` (already a transitive dependency of smithay's own `wl_shm`
+ handling, just not previously used directly by this crate) maps it
+ read-only, and the three channel slices are read out by hand
+ (`u16::from_ne_bytes` per pair - no cross-endianness concern, client
+ and compositor are always the same machine) before handing them to
+ `drm-rs`'s `set_gamma`. Built and tested; **not yet live-verified
+ against real hardware**, for the same nested-testing-environment reason
+ as `output_power.rs` above (needs a real restart).
+
+- **New feature: window animations** (`general.animations`/
+ `general.animation_duration`) - these two config keys were validated/
+ defaulted by `crates/config` but nothing ever read them, same dead-config
+ bug class already found and fixed for `workspace.count`. Wiring lives in
+ `crates/srdwm/src/main.rs`'s new `apply_general_settings`, read into two
+ new `WindowManager` fields (`animations_enabled`, `animation_duration_ms`)
+ the same way `apply_workspace_count` already reads `workspace.count` --
+ and, found as a side effect of tracing this same wiring gap,
+ `general.window_gap` had the identical bug (`TilingConfig::default()`
+ hardcoded `gap_inner: 8, gap_outer: 16` regardless of what `init.lua`
+ set; now read into `WindowManager.tiling` there too).
+ Deliberately geometry-only, no fade/scale-of-content: content is
+ composited through `self.space` (see `resync_stacking_order`'s doc
+ comment for why per-window custom render elements were ruled out earlier
+ this session as the content path), which has no per-element alpha/scale
+ knob independent of the rest of the output - reintroducing that path
+ just for animations would have reopened the not-fully-understood
+ z-order risk that path carried before its root cause was found. What
+ *is* safe to animate through `self.space` is exactly what interactive
+ drag/resize already proves out on every single motion frame: a
+ `Window.geometry` change applied via `map_element` and (on a size
+ change) `xdg_toplevel.configure`. `crates/wayland/src/state.rs` reuses
+ that exact mechanism at a fixed ~60fps cadence instead of on pointer
+ motion:
+ - `Window` (core) gained `anim_from: Option<Rect>`, set by
+ `WindowManager::toggle_maximize`/`toggle_fullscreen` to the geometry
+ just moved *from*, only when `animations_enabled`. Interactive drag/
+ resize never sets it, so those still track the pointer 1:1 with no
+ tween.
+ - `sync_geometry` (wayland) takes (reads and clears) `anim_from`; if set
+ and different from the target, it registers a `WindowAnim` (eased
+ ease-out-cubic interpolation) and applies the tween's current rect
+ instead of jumping straight to `geometry`.
+ - `CompState::tick_animations`, called once per frame from both
+ backends' `poll_events` (`render_frame`/`render_udev_frame`), advances
+ every in-flight tween and re-runs `sync_geometry` for it; a finished
+ tween is dropped *before* its last call so that call lands exactly on
+ `Window.geometry`, not on the eased curve's last sub-pixel step.
+ - New windows get a small "open-slide" tween too (`new_managed_window`
+ sets `anim_from` to a rect ~24px below the resting position, same
+ size) - position-only, deliberately no resize, since a freshly-
+ mapping client's first paint may not have arrived yet and repeatedly
+ reconfiguring it to intermediate sizes during that window risked
+ looking worse than no animation, not better. Window *close* is not
+ animated - the client's resources are already gone by the time
+ srdwm knows about it, so there is nothing left to tween without
+ rendering a static last-frame texture, a materially different (and
+ separate) problem, left for a later pass.
+ Covered by unit tests in both `srdwm-core` (`maximize_records_anim_from_
+ when_animations_enabled`, the disabled-config counterpart, and the
+ fullscreen equivalent) and `srdwm-wayland` (`WindowAnim`'s easing:
+ starts at `from`, ends exactly at `to`, strictly between on every axis
+ midway). **Not yet live-verified** against a real interactive maximize/
+ fullscreen/open, for the same nested-testing-environment reason as the
+ DPMS/gamma-control entries above (needs a real restart).
+ **Note for whoever restarts next**: the shipped `~/.config/srd/init.lua`
+ currently has `srd.set("general.animations", false)` (carried over from
+ a prior Hyprland config, back when srdwm had no animation support at
+ all) - with this wiring in place that line now actually takes effect
+ and animations will stay off under the current config. Flip it (or
+ remove the line, since the built-in default is `true`) to see this.
+
+- **Fixed: dock/panel state going stale after a compositor keybinding**
+ (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`,
+ `srd.workspace.next()`/`.prev()`/`.switch()`) - both were long-standing,
+ explicitly documented gaps in `docs/PANEL_SUPPORT_TODO.md`'s P1 section:
+ `crates/config` is the platform-agnostic scripting engine (shared with
+ the X11/macOS backends) and only ever holds a `WindowManager` reference,
+ never `CompState`, so a Lua-driven state change had no way to reach
+ `foreign_toplevel::send_state`/`workspace::broadcast_active_workspace`,
+ which are Wayland-protocol-specific and live one layer up. Every *other*
+ trigger (pointer titlebar actions, a client's own request, the
+ `SUPER+scroll` workspace gesture) already re-broadcast correctly; only
+ the Lua-bound paths were silently stale. Closed with the periodic-diff
+ option that entry already named as the alternative to threading a
+ callback through `WindowManager`: `CompState::tick_dirty_broadcasts`,
+ called once a frame from both backends' `poll_events` (same cadence as
+ the animation tween's `tick_animations`) --
+ `foreign_toplevel::broadcast_dirty_state` diffs `maximized`/`minimized`/
+ `fullscreen` per window against what was last sent and re-broadcasts
+ anything changed; `workspace::broadcast_dirty_active` diffs the current
+ workspace id and only calls `broadcast_active_workspace` (real protocol
+ traffic to every handle, not a cheap comparison) on an actual change.
+ This catches the specific documented gap and, being a diff against live
+ `WindowManager` state rather than a per-call-site hook, any future
+ keybinding/API that changes the same state without its own broadcast
+ call, with nothing further to remember.
+
+- **FIXED: sustained high CPU usage, and no clean shutdown on `SIGTERM`.**
+ Found chasing a real report - a peer session working on the AGS shell
+ measured srdwm at 33% CPU on a 4-core machine while it was the live
+ compositor, and separately found a stale `srdwm-<display>.sock` left
+ behind after a session switch away from srdwm, with nothing listening on
+ it. Both were real, and both are now fixed:
+ - **The winit (nested/dev) backend's render loop had no pacing at all.**
+ `poll_events` called `render_frame` -> full render + `swap_buffers`
+ every single iteration with nothing in between that ever blocked:
+ `pump_winit`'s `dispatch_new_events` polls and returns immediately
+ either way, and smithay 0.7.0's winit backend hardcodes
+ `vsync: false` on the EGL surface it creates (every entry point into
+ `backend/winit/mod.rs` does this, not just the one this backend uses
+ for its custom `WindowAttributes` - confirmed by reading the crate
+ source directly), so `swap_buffers` never waits for a display refresh
+ either. Measured live in an isolated nested instance, zero windows
+ open: **52.5% of one core, sustained** (`ps -o %cpu`). Fixed by giving
+ `idle_event_loop.dispatch` (already called every tick for `ext_idle_
+ notify_v1`'s timers) a real timeout - the remaining budget until
+ `TARGET_FRAME_TIME` (1/60s) has elapsed since the last frame, instead
+ of always `Duration::ZERO` - rather than adding a second, separate
+ sleep. Re-measured after the fix, same isolated instance, same zero
+ windows: **7.1-7.4%**, roughly a 7x reduction. The udev/DRM backend
+ was not affected the same way - its `poll_events` already blocks on
+ `event_loop.dispatch(Some(Duration::from_millis(16)), ...)` - but see
+ the border-buffer fix below for a *second*, independent way a bordered
+ window could have kept it rendering (and page-flipping) every frame
+ regardless of pacing.
+ - **Border strips were rebuilt from scratch every render frame, in both
+ backends, with a fresh `Id` every time** - the top strip via
+ `decoration::render_border_top` + a brand-new `MemoryRenderBuffer`,
+ the other three via `SolidColorRenderElement::new(Id::new(), ...)`.
+ Confirmed by reading smithay 0.7.0's `OutputDamageTracker::
+ damage_output_internal` directly: it looks up each element's previous
+ state by `Id` and falls back to `.unwrap_or(true)` ("damage it") when
+ no match is found. A fresh `Id` every frame means no match is *ever*
+ found, so every bordered window's border was marked damaged on every
+ single frame, forever - not a wider damage rect occasionally, actual
+ damage unconditionally, every time, for as long as any window with
+ `border_width > 0` was on screen (the default, so in practice always).
+ On the udev backend this meant `has_damage` was permanently true and a
+ real DRM page flip fired every ~16ms regardless of whether the screen
+ had changed at all - the output could never actually go idle. Fixed
+ by caching the top strip's bitmap the same way (and at the same
+ trigger points - creation, a resize, a rule re-applying) the titlebar
+ already was, in a new `CompState::border_top_decorations`, and by
+ giving the other three strips a *persistent* `SolidColorBuffer` per
+ window (`CompState::border_side_buffers`) updated in place via
+ `.update()` every frame instead of rebuilt - `SolidColorBuffer::
+ update` only bumps its internal commit counter when the size or
+ colour actually changed, which is what lets the tracker correctly see
+ "nothing changed" on a static screen. Since caching the titlebar-
+ adjacent border-top bitmap meant it could go stale on a focus change
+ (colour depends on focused/unfocused) where nothing was rebuilding it
+ before, `set_window_activated` (the real focus chokepoint) now calls
+ `redraw_decoration_buffer` on an actual activation change too - which
+ incidentally fixes a second, pre-existing bug in its own right: the
+ *titlebar text* colour was never being refreshed on focus change
+ either, only whenever some unrelated resize happened to trigger a
+ redraw regardless.
+ - **No `SIGTERM`/`SIGINT` handler at all.** Default disposition is
+ immediate termination with no Rust `Drop` impl ever running, so an
+ external shutdown (a session manager or `systemd-logind` ending the
+ session normally, not a crash or a `kill -9`) skipped `IpcServer::
+ drop` (`crates/wayland/src/ipc.rs`) and left its socket file behind.
+ Confirmed live testing the fix: the *old* binary genuinely ignored
+ `SIGTERM` outright (`SigCgt` in `/proc/<pid>/status` showed something
+ in the dependency chain - not srdwm's own code, which installed no
+ handler - already catching it) and needed `SIGKILL` to die at all;
+ the new binary, with `crates/srdwm/src/main.rs`'s `install_signal_
+ handlers` called first thing in `main`, exits cleanly on `SIGTERM` and
+ removes every socket (`srdwm-<display>.sock` and the Wayland display
+ socket itself) as part of that exit. The handler itself only sets a
+ process-wide `AtomicBool` (the one thing async-signal-safe to do);
+ the main loop polls it once per iteration and, if set, routes through
+ `running.set(false)` - the exact same path `srd.quit()` already
+ uses - rather than a second, separate shutdown path. Unix only
+ (`cfg(unix)`, covering Linux and macOS): `libc` added as a direct,
+ `cfg(unix)`-gated dependency of `crates/srdwm` for this (already
+ present transitively at the identical version, so no new dependency
+ tree growth); Windows gets a no-op stub, `SIGKILL` cannot be caught by
+ any process on any platform so a teardown that escalates straight to
+ that is not fixable from here.
+
+- **A priority pass driven by a deep-dive comparison against sway, Hyprland,
+ river, i3, bspwm, dwm, komorebi, GlazeWM, yabai, AeroSpace and Amethyst**
+ (full writeup published as a standalone artifact; the gaps found there
+ drove this whole pass, roughly in priority order):
+ - **FIXED: X11 keybindings silently broke under Num Lock.**
+ `grab_keybindings` grabbed each combo's raw modifier mask only, not
+ once per lock-modifier combination (none/Num Lock/Caps Lock/both) --
+ every real X11 WM (i3, bspwm, dwm) does this because `XGrabKey`
+ matches state exactly, not as a subset, so a real keypress's state
+ (which always includes whichever lock modifiers happen to be toggled)
+ never matched a grab that didn't. Num Lock's own modifier bit isn't
+ fixed by the X11 spec (unlike Caps Lock, always `ModMask::LOCK`) --
+ found via `get_modifier_mapping` + the keycode `XK_Num_Lock` (`0xff7f`)
+ resolves to, at connect time. `crates/x11/src/lib.rs`.
+ - **FIXED: a much larger dead-config surface than previously known.**
+ Auditing every `set(...)` in `crates/config`'s defaults block against
+ real read sites (the same class of bug `window_gap`/`general.
+ animations` turned out to be) found ~80 more validated-but-never-read
+ keys. Wired the ones with an unambiguous, already-rendered counterpart:
+ `theme.decorations.title_bar.background`/`border.active_color`/
+ `border.width` (a new `srdwm_core::ThemeConfig` on `WindowManager`,
+ replacing hardcoded Nord-palette constants that happened to match --
+ live on both the Wayland and X11 backends), `workspace.names` (a new
+ `WindowManager::rename_workspace`), and `workspace.auto_back_and_forth`
+ (sway's "reselecting the active workspace jumps back to the previous
+ one" behavior, a new `previous_workspace` field). The rest (`debug.*`,
+ `performance.*`, `platform.*.use_*`, the `layout.tiling`/`dynamic`/
+ `floating` namespace, `window.remember_*`) are real features, not
+ one-line wiring fixes, and stay open - listed below.
+ - **FIXED: `WindowMatch` was the least capable rule matcher of any WM
+ compared**, including static-config dwm - only exact `class` and
+ substring `title_contains`. Added `title_regex`/`class_regex` (Rust
+ `regex` crate, case-sensitive unless the pattern starts with `(?i)`)
+ and `instance` (X11 `WM_CLASS`'s instance half) to `srdwm_core::
+ WindowMatch`, all ANDed with the existing fields, and to `srd.rule`'s
+ Lua API. Found and fixed a second, more fundamental bug in the same
+ pass: **the X11 backend never read `WM_CLASS` at all**, so `app_id`
+ was permanently empty on every X11 window and every class-based rule
+ silently failed to match anything - the same root cause already found
+ and fixed for native Wayland windows earlier this project
+ (`with_toplevel_app_id`'s doc comment), just never ported to X11.
+ `crates/x11/src/lib.rs`'s new `window_class`.
+ - **NEW: scratchpad** (`srd.window.scratchpad()`/`.scratchpad_show()`) --
+ sway's `move scratchpad`/`scratchpad show`, the single most-used
+ "quick terminal" pattern in tiling WMs and the biggest single feature
+ gap the comparison found. `WindowManager::scratchpad_add`/
+ `scratchpad_show` reuse the existing `minimized`+`visible_windows`
+ mechanism (a new `Window::scratchpad` field is purely a pool-
+ membership marker) rather than inventing new visibility plumbing, so
+ it rides on the same generic `sync()` diff in `crates/srdwm/src/
+ main.rs` that already drives minimize/restore/geometry/decoration for
+ every backend - no Wayland- or X11-specific wiring needed at all.
+ Showing a hidden scratchpad window moves it onto whichever workspace
+ is current, matching sway rather than pinning it to wherever it was
+ hidden from.
+ - **NEW: `srd`'s IPC gained event subscription** (`{"cmd":"subscribe"}`,
+ `srd subscribe`) - the single highest-leverage gap the comparison
+ found: every compositor compared (sway, Hyprland, i3, bspwm) has an
+ event-subscribe side to its IPC; `srd`'s was poll-only, which is
+ exactly why an AGS peer session had to poll raw `wlr-foreign-toplevel`
+ from a separate Python helper instead of using this socket at all. A
+ `subscribe` connection gets an immediate snapshot, then one more
+ `{"event":"clients",...}` line every time the window list actually
+ changes (diffed once a tick, skipped entirely when nothing did) --
+ kept open rather than the usual one-request-one-response-close, the
+ one exception to that shape. Live-verified end to end against a real
+ client in a nested instance: initial empty snapshot, a push on
+ Alacritty opening, a push back to empty on it closing.
+ **`crates/wayland/src/ipc.rs` moved to `crates/platform/src/ipc.rs`**
+ as part of this - it never actually touched anything Wayland-specific
+ (pure `WindowManager` + sockets), so this also closed a separate,
+ bigger gap the comparison found: **the X11 backend had no IPC server
+ at all**, not even the pre-existing one-shot version. Wiring it in
+ also required fixing `X11Platform::poll_events`, which used to block
+ indefinitely on `wait_for_event()` - meaning the IPC socket (and
+ everything else) would've gone unresponsive for as long as nothing
+ happened on the X11 connection at all. Replaced with a bounded
+ `poll(2)` (~16ms, matching the Wayland backends' own cadence) on the
+ connection's own fd. `crates/ctl` (the `srd` CLI) updated to pick the
+ right `WAYLAND_DISPLAY`-vs-`DISPLAY` socket-naming key via `srdwm_
+ platform::detect()` (previously Wayland-only, hardcoded) and to
+ support `srd subscribe`.
+
+- **New feature: `zwlr_output_management_v1`** - the top item on the
+ field-survey comparison's cross-platform priority list. Lets a settings
+ panel enumerate every output (name, description, physical size, make/
+ model, every supported mode, current position/scale/transform/enabled
+ state) and request changes. `crates/wayland/src/output_management.rs`,
+ hand-written against `wayland-protocols-wlr`'s raw server bindings, same
+ pattern as `foreign_toplevel.rs`/`workspace.rs`. Not `Option`-gated per
+ backend like DPMS/gamma-control - enumeration and applying position/
+ scale/transform go through `Output::change_current_state`, which already
+ works identically on both.
+ - **Deliberately conservative on `apply`/`test`**: disabling/enabling a
+ head (srdwm has no concept of an output that exists but isn't mapped)
+ and switching resolution/refresh rate (real DRM mode-setting - finding
+ the matching connector mode and reprogramming the CRTC, substantial
+ hardware-dependent work, and this environment has no multi-mode
+ hardware to verify it against regardless) both fail honestly (`failed`)
+ rather than silently no-op. A `set_mode`/`set_custom_mode` matching the
+ head's *already-current* mode - the common case, since a real panel
+ echoes back existing state alongside whatever it's actually changing --
+ is accepted as a no-op.
+ - Moving a head keeps three separately-cached copies of output position
+ in step (`Output` itself, `CompState::outputs` used for hit-testing,
+ and on the udev backend `UdevHead::location` used to translate render
+ geometry into head-local space) via a dedicated `apply_output_position`
+ helper - each exists for its own documented reason and would
+ otherwise silently drift apart after an output-management-driven move.
+ - Hotplug and `apply()`-driven changes share one path: `broadcast_dirty_
+ outputs`, called once a frame from `tick_dirty_broadcasts` (the same
+ "diff once a tick, only do real work on a real change" shape as
+ `foreign_toplevel`/`workspace`'s own broadcasts), diffs the live output
+ set/state against what was last sent and creates/destroys head objects
+ for anything that appeared/disappeared, re-sending current state on
+ everything else.
+ - **Live-verified end to end**, not just unit-tested: a `pywayland`-based
+ client generated from the real protocol XML, run against a nested
+ instance. Confirmed correct `head`/`mode` enumeration (name
+ `srdwm-wayland`, description, make `srdwm`/model `winit`, 8 accumulated
+ modes from the nested window's own resize history, current mode,
+ position, transform, scale) matching the actual running instance; a
+ real `create_configuration` -> `enable_head` -> `set_position(777,
+ 333)` -> `apply()` round trip returned `succeeded` and the head's
+ position genuinely changed, confirmed by the compositor pushing a
+ fresh `position` event on the same client handle; a `disable_head` ->
+ `apply()` correctly returned `failed` rather than silently doing
+ nothing. No errors or panics in the compositor log across all three
+ rounds.
+
+## Live daily-driving pass: border occlusion, real focus sync, EWMH init (added this pass)
+
+srdwm became the actual live compositor for the first time this session
+(not nested, not tested via Hyprland), which surfaced three real bugs no
+amount of nested/unit testing had caught:
+
+- **Border-bleed-through.** `smithay::desktop::space::render_output`
+ always composites `custom_elements` (this codebase's borders/decoration)
+ above *every* window's own content, with no way to interleave by real
+ stacking order - confirmed live: a cascade of terminals showed every
+ earlier window's border strip as solid lines cutting through the
+ frontmost window. Fixed with occlusion-aware clipping:
+ `srdwm_core::Rect::subtract_all` decomposes each border strip against
+ every window stacked in front of it into the sub-rectangles still
+ visible, rendered from a growable per-window pool of persistent
+ `SolidColorBuffer`s (`crates/wayland/src/elements.rs`'s
+ `visible_border_fragments`/`border_fragment_buffer`) since the fragment
+ count varies frame to frame with whatever's currently stacked above.
+- **`Platform::focus` was dead code.** Declared in the trait, implemented
+ by both Wayland backends, but never called anywhere in `main.rs`'s event
+ loop - so IPC/Lua-driven focus changes (`srd dispatch focus`,
+ scratchpad) updated `WindowManager`'s own bookkeeping but never real
+ Wayland keyboard focus or X11 `_NET_ACTIVE_WINDOW`. Caught while
+ verifying `_NET_ACTIVE_WINDOW` for a peer session's dock. Fixed by
+ wiring `sync()` (`crates/srdwm/src/main.rs`) to call `platform.focus()`
+ on the resolved focus target every tick, and by having both backends'
+ `Platform::focus` impls call the full `crate::input::focus_window` path
+ instead of only touching core state.
+- **`_NET_CLIENT_LIST` could start life stale.** Not reproduced under a
+ clean session (confirmed via a same-machine peer's repro harness: both
+ graceful close and SIGKILL prune the property within a second), but a
+ session restarting while the X root window's properties persist across
+ it was a real, cheap-to-close gap - nothing guaranteed a client only
+ ever read the property after this compositor's own first
+ `update_net_client_list()` call. `EwmhState::connect`
+ (`crates/wayland/src/xwayland.rs`) now clears `_NET_CLIENT_LIST`/
+ `_STACKING` immediately after interning the EWMH atoms, before any
+ window has ever mapped - a session can no longer start life advertising
+ windows it has never seen.
+
+None of these three were found by reading the code or by the existing
+test suite - all three came from actually running srdwm live, opening
+real windows, and checking real EWMH/render state against what a real
+client would see.
+
+## AGS dock parity: maximize/fullscreen live-resync, IME, `floating` in IPC (added this pass)
+
+Raised by the same AGS peer session porting its dock off Hyprland-only
+`hyprctl` calls, this time chasing a real user-reported symptom rather
+than a protocol trace:
+
+- **A maximized window didn't grow when a dock released its reserved
+ space.** `WindowManager::set_monitors` (`crates/core/src/manager.rs`,
+ called whenever a layer-shell client's exclusive zone changes) already
+ recomputed `Monitor::geometry`/`full_geometry` correctly, but never
+ touched the `geometry` of a window that was *already* maximized or
+ fullscreen - it kept whatever rect it was sized to at the moment it was
+ toggled on. An auto-hide dock dropping its zone to 0 while a window was
+ maximized therefore left that window stuck at its old, dock-shrunk size
+ until manually un-maximized and re-maximized. Fixed: `set_monitors` now
+ re-syncs any maximized/fullscreen window's `geometry` to its monitor's
+ current `geometry`/`full_geometry` (whichever mode it's in) in the same
+ pass. Confirmed `maximized`/`fullscreen` map deliberately onto
+ Hyprland's monocle/true-fullscreen modes respectively (`toggle_maximize`
+ targets the exclusive-zone-shrunk usable rect, `toggle_fullscreen` the
+ true full rect) - not incidental, an existing doc comment on
+ `toggle_fullscreen` already said as much. Three new regression tests.
+- **`zwp_text_input_manager_v3` + `zwp_input_method_v2`** - see
+ `docs/PANEL_SUPPORT_TODO.md`'s P2 entry for the full writeup.
+- **`floating` added to `srd clients`.** The dock's overlap check needs to
+ tell a layout-placed tiled window sitting flush against its reserved
+ edge (expected) from a user-dragged floating window actually overlapping
+ it (a real overlap) - geometry alone can't distinguish the two.
+ `Window.floating` was already a first-class field; this was a direct
+ passthrough in `crates/platform/src/ipc.rs`.
+
+## Live daily-driving pass, round 2: real stacking-order bug, titlebar occlusion gap (added this pass)
+
+Found live, on the real restarted (non-nested) session, by actually opening
+overlapping windows and screenshotting rather than reading code:
+
+- **Focus never visibly "stuck" to a window - root cause.** `main.rs`'s
+ `sync()` drove its per-window `apply_geometry`/`redraw_decoration` loop
+ off `WindowManager::visible_windows()`, which iterates a `HashMap` in
+ arbitrary order. Both of those `Platform` calls end up calling
+ `CompState::sync_geometry`, which - as a documented side effect of
+ smithay's own `Space::map_element` - re-raises whichever window it's
+ called for to the top of `Space`'s real render order. Since `sync()` runs
+ on essentially any dirty event (a keystroke, a resize frame, a workspace
+ poll - many times a second in normal use), every single tick re-shuffled
+ every visible window's on-screen stacking order to whatever the `HashMap`
+ happened to yield that pass, completely unrelated to which window was
+ actually focused. Confirmed live: a freshly-launched, logically-focused
+ `gnome-calculator` window (`srd clients` reporting `focused: true`)
+ rendered entirely *behind* an older, unfocused terminal on every
+ screenshot. Fixed: `sync()` now iterates `visible_windows_front_to_back()`
+ (real stacking order) reversed, bottom-to-top, so each pass's cascade of
+ re-raises ends deterministically with the true topmost window raised
+ last - restoring the same order it started with instead of scrambling
+ it every tick. This was very likely the dominant contributor to several
+ distinct-sounding live reports this session (focus not sticking after
+ interacting with a panel, borders/decoration looking desynced from their
+ window) rather than three separate bugs.
+- **A background window's titlebar bled through a foreground window.** The
+ border-occlusion fix earlier this pass (see the section above) only ever
+ touched the border *strips* - the titlebar bitmap itself (`self.
+ decorations`, holding the title text and min/max/close buttons) was still
+ pushed into `custom_elements` completely unconditionally, with a comment
+ claiming "titlebar/content ... occlude correctly on their own paths",
+ which was true for content but never actually true for the titlebar.
+ Reported live as "still see the behind window's bar." Fixed in both
+ `udev.rs` and `winit.rs`: the titlebar push now gets the same all-or-
+ nothing occlusion test the top border strip already used (skip the draw
+ entirely once the titlebar's rect is fully covered by windows stacked in
+ front of it).
+- **Cursor appearance investigated, not a bug.** The built-in arrow (white
+ fill, black outline) renders correctly - confirmed by cropping the exact
+ same bitmap over a light background (crisp, fully outlined) versus a
+ dark terminal (outline blends into the background, leaving only the
+ white fill visible, which reads as "looks weird" without being
+ corrupted). A real but purely cosmetic contrast issue, not a rendering
+ bug; not touched this pass since it needs a considered redesign (a halo/
+ double-outline, the same technique real cursor themes use) rather than a
+ one-line fix, and the existing bitmap has its own passing shape-
+ regression tests that a hasty change could easily break.
+
+## Titlebar window menu + global menu support (added this pass)
+
+Two features, both requested directly: make the titlebar/border
+interactions "highly functional" (the user's own words, after several
+rounds of live bug reports), and start on optional global-menu support now
+that srdwm is a real, independent compositor rather than something
+adapting to an existing one.
+
+- **Right-click titlebar window menu.** Right-click on a titlebar
+ previously did nothing at all - the only right-button behaviour
+ anywhere was the SUPER+right-drag resize gesture, and only with the
+ modifier held. `context_menu.rs` (state/hit-testing, pure and unit-
+ tested) + `decoration::render_context_menu` (the pixels, also unit-
+ tested) + wiring in `input.rs`/`state.rs`/`udev.rs`/`winit.rs`. Four
+ actions - Minimize, Maximize/Restore (label reflects current state),
+ Always on Top (checkmark-prefixed once pinned), Close - no submenus, no
+ live hover highlight (would need the render buffer rebuilt on every
+ motion event over the menu; not worth the per-frame cost for a first
+ pass). A press anywhere while the menu is open resolves it (selects a
+ row) or dismisses it; neither case falls through to whatever a normal
+ click there would have done. Cleaned up if the window it belongs to
+ closes out from under it (crash, kill, or its own Close action racing
+ ahead), on both the native and XWayland removal paths.
+- **Middle-click titlebar lowers the window.** The convention several X11
+ WMs (twm, fvwm, IceWM) have always had; srdwm never did. New
+ `WindowManager::lower_window` (opposite of the existing `raise_window`,
+ same pinned-window protection via `restack_pinned`).
+- **Global menu: address over the protocol, content over D-Bus.** Per
+ design input from an AGS peer session already building the consuming
+ shell: a compositor should carry the menu's *address* (D-Bus bus name +
+ object paths) and leave the content on D-Bus, where GTK4 already
+ exports it as a real `GMenuModel` (`org.gtk.Menus`/`org.gtk.Actions`)
+ that GTK consumes natively - submenus, toggles, accelerators, icons,
+ all for free, with no model-walking/rendering code of this compositor's
+ own to have gaps in. New `srdwm_core::GlobalMenu { bus_name, menu_path,
+ app_path, window_path }`, exposed as `global_menu` on every `srd
+ clients` entry (`null` when a window hasn't exported one).
+ - **XWayland**: `EwmhState::read_global_menu` (`xwayland.rs`) reads the
+ four `_GTK_*`/`_UNITY_OBJECT_PATH` atoms straight off the X11 window,
+ refreshed on every real focus change (the properties are usually set
+ once, shortly after the client's own D-Bus registration completes,
+ which can race its window's initial map - reading only at map time
+ would miss a client that finished registering a moment later; a focus
+ change is a natural, already-existing hook, and the menu only needs
+ to be current for whichever window is actually focused anyway).
+ - **Wayland-native**: GTK's own private `gtk_shell1`/`gtk_surface1`
+ protocol, generated at compile time from a vendored copy of GTK's own
+ XML (`crates/wayland/protocols/gtk-shell.xml`) via `wayland-scanner`
+ - no published `wayland-protocols-*` crate exists for this one, so
+ it's generated the same way `wayland-protocols-wlr` generates its own
+ bindings internally, server-side only. `gtk_shell.rs` sends
+ `capabilities` (both `global_app_menu`/`global_menu_bar` bits) right
+ after a client binds the global - GTK only bothers calling
+ `set_dbus_properties` later if it saw that first - then stores
+ whatever a `gtk_surface1.set_dbus_properties` request carries onto
+ the matching `WindowId`'s `global_menu`, and clears it again if the
+ object is destroyed while the window itself stays open.
+ - **Known, expected limitation, not a bug**: even with a perfect
+ address, most non-GTK apps show nothing - the app has to actually
+ export a `GMenuModel` over D-Bus itself (`appmenu-gtk-module` for
+ real GtkMenuShell-based GTK apps; Qt and anything self-drawing its
+ own menus, LibreOffice's VCL toolkit included, generally won't).
+ Flagged by the same AGS session ahead of building the consumer, from
+ their own live testing against LibreOffice.
+ - Not yet live-verified against a real client on either path - built
+ and compiles cleanly (workspace-wide, including the new
+ `wayland-scanner`/`wayland-backend` build-time codegen dependency),
+ pending the same restart as everything else this pass.
+
+## Live daily-driving pass, round 3: real full_geometry bug, titlebar fragments, `srd monitors` (added this pass)
+
+All found live, on the real session, chasing specific user-reported
+symptoms rather than by reading code first:
+
+- **Fullscreen never actually covered a bar/dock - confirmed by
+ triggering it and reading the resulting geometry back over IPC.**
+ Landed at exactly the reserved-zone-shrunk rect on a 1920x1080 output,
+ not the true output. Root cause: all three real backends'
+ `monitors()` (`udev.rs`, `winit.rs`, `crates/x11`) construct `Monitor`
+ via `Monitor::new(id, name, rect)` where `rect` is *already*
+ zone-shrunk, and `Monitor::new` defaults `full_geometry` to whatever
+ `geometry` was constructed with - so `full_geometry` was silently
+ identical to `geometry` for every real monitor either Wayland backend
+ ever reported. `toggle_fullscreen`'s entire "ignore the reserved zone"
+ design was a no-op in practice, and the existing unit tests (which
+ construct `Monitor` by hand with the two fields genuinely different)
+ couldn't have caught it - they exercise the invariant, never the
+ construction path that was collapsing it. Fixed in `udev.rs`/
+ `winit.rs` by setting `full_geometry` separately from the true output
+ size; `crates/x11` doesn't have the same zone-shrinking step at all, so
+ it wasn't affected. Live-reverified twice more afterward: once via a
+ real `waybar` in a nested instance with `srd monitors` (see below)
+ showing `geometry`/`full_geometry` correctly split by the bar's exact
+ 36px, and independently by the AGS peer session against their own real
+ dock.
+- **Titlebar bleed-through, the sequel.** The occlusion fix from the
+ previous pass (see above) was all-or-nothing: skip the titlebar draw
+ only once *fully* covered. A titlebar only *partially* covered - the
+ common case for overlapping/cascaded windows - still drew in full,
+ bleeding through the covered part, reported live as the same "still
+ see the behind window's bar" complaint the earlier fix was supposed to
+ close. Now fragment-clipped the same way the three solid border strips
+ already were, using `MemoryRenderBufferRenderElement::from_buffer`'s
+ `src` parameter to crop the bitmap itself per visible fragment instead
+ of drawing the whole thing or nothing.
+- **`Bottom`/`Background` layer surfaces were unclickable, full stop.**
+ Found while investigating a live "clicking the dock does nothing"
+ report (the dock itself turned out to be `Layer::Top`, already
+ checked, so not the cause of that specific report - but a real,
+ separate gap all the same): `layer_surface_under` only ever checked
+ `Layer::Overlay`/`Layer::Top`; nothing in the click or motion path ever
+ checked `Bottom`/`Background` at all, so a surface at either layer
+ (a desktop-icons layer, a wallpaper daemon wanting clicks) could never
+ receive pointer input regardless of what was or wasn't covering it.
+ Fixed by checking `Bottom`/`Background` as a final fallback, after
+ windows/decoration/`Overlay`/`Top` all come up empty - correct
+ ordering, since `Bottom`/`Background` are meant to sit *behind* normal
+ windows while `Overlay`/`Top` sit in front of everything.
+- **`srd monitors`.** Requested directly by the AGS peer session after
+ two separate live-debugging rounds (the maximize-past-dock and
+ fullscreen-past-dock questions above) each took several turns of
+ indirect reasoning that reading this back directly would have settled
+ immediately. Returns `geometry` (usable, what maximize/tiling target)
+ and `full_geometry` (true output, what fullscreen targets) per
+ monitor. Live-verified with a real `waybar` in a nested instance.
+- **The `srd` CLI binary itself hadn't been reinstalled all session.**
+ Every fix this whole multi-pass session reinstalled `srdwm` (the
+ compositor); nothing had reinstalled the separate `srd` binary (the
+ control CLI) even once, so `srd monitors` above genuinely didn't exist
+ on the invoking side until this was caught and fixed. `srd` isn't a
+ long-running process, so this needed no restart to pick up - unlike
+ `srdwm` itself, every `srd` invocation from this point on already sees
+ the current binary.
## Not implemented anywhere yet
@@ -571,7 +2049,5 @@ Wayland backend section above. What is left:
- **Multi-GPU** - only the primary GPU's connectors are driven. A GPU
appearing or disappearing is logged and ignored.
-- Animations (`general.animations`/`animation_duration` config keys exist
- and are read into defaults, but nothing consumes them yet).
- A native GUI settings app (the legacy project's `GUI_SETTINGS.md` was
pure design doc even in C++; not revisited here).