srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/state/geometry.rs
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-02-15 14:56:00 +0200
committersrdusr <[email protected]>2025-02-15 14:56:00 +0200
commit0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch)
tree7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/state/geometry.rs
parent413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff)
downloadsrdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz
srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has diverged with its own separate fixes today. Nothing here is reviewed or curated yet - this exists purely so none of this work can be lost to a git operation, disk issue, or worktree cleanup while that reconciliation happens.
Diffstat (limited to 'crates/wayland/src/state/geometry.rs')
-rw-r--r--crates/wayland/src/state/geometry.rs249
1 files changed, 231 insertions, 18 deletions
diff --git a/crates/wayland/src/state/geometry.rs b/crates/wayland/src/state/geometry.rs
index 21cac8d..715881b 100644
--- a/crates/wayland/src/state/geometry.rs
+++ b/crates/wayland/src/state/geometry.rs
@@ -1,5 +1,16 @@
use super::*;
+/// How long `sync_geometry` waits for a client to catch up to a previous
+/// size-changing configure before giving up on the throttle and sending a
+/// new one anyway - see `pending_size_configure`'s own doc comment for
+/// the throttle itself. Generous relative to any real client's own
+/// resize-and-recommit latency (a terminal reflowing text, a browser
+/// re-laying-out a page), so this essentially never fires in practice;
+/// it exists purely as the same kind of bounded self-heal this session's
+/// DRM flip-pending watchdog already uses, not a tuning knob expected to
+/// matter day to day.
+const CONFIGURE_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100);
+
impl CompState {
/// Re-raises always-on-top windows in the `Space`.
@@ -17,6 +28,110 @@ impl CompState {
}
}
+ /// `Self::effective_frame`, but as a free function taking only the two
+ /// fields it actually needs (`wm`, `id_to_window`) instead of `&self` --
+ /// a render loop holding `self.udev`/`self.backend` mutably borrowed
+ /// can't also pass `&self` to a method, since Rust can't see through a
+ /// method call to know it only touches two unrelated fields. Called
+ /// through the inherent method below wherever a plain `&self` is
+ /// available (input handling, `redraw_decoration_buffer`); this
+ /// version exists for the render loops specifically.
+ pub(crate) fn effective_frame_of(wm: &Rc<RefCell<WindowManager>>, id_to_window: &HashMap<WindowId, DWindow>, id: WindowId, geom: srdwm_core::Rect) -> srdwm_core::Rect {
+ // A version of this function briefly (this same session) skipped
+ // the committed-size correction below entirely during an active
+ // resize, on the reasoning that trusting the client's stale last
+ // commit over this compositor's own live drag target was what made
+ // the border visibly lag behind content while dragging. Reverted:
+ // that fix was real for *position*-independent reasoning but wrong
+ // in a more important way - every caller of this function that
+ // reads a *bitmap*-backed element (the titlebar, the top/bottom
+ // border strip's own rounded-corner bitmap, both built by `redraw_
+ // decoration_buffer`, itself only called on a real client *commit*,
+ // not on every resize step) uses this rect's width/height to size
+ // the `src` crop rectangle it samples that bitmap with. Making this
+ // function return the *live* drag target while the underlying
+ // bitmap was still sized for whatever the *last commit* actually
+ // was means that crop can end up larger than the real bitmap's own
+ // stored dimensions - `MemoryRenderBufferRenderElement::from_
+ // buffer` does not validate `src` against the texture's real size,
+ // so an oversized crop reads as an out-of-bounds texture sample
+ // (stretched/repeated/garbage pixels, not a clean error) for as
+ // long as a fast resize keeps outrunning the client's own recommit
+ // rate - a worse, more visibly broken failure mode than the
+ // one-frame-stale lag it replaced. Fixing the lag properly needs
+ // `redraw_decoration_buffer` itself rebuilding on every resize
+ // step, not just on commit, which is real, separate scope - not
+ // yet done.
+ let Some(w) = wm.borrow().window(id).cloned() else { return geom };
+ let Some(dwindow) = id_to_window.get(&id) else { return geom };
+ let content = dwindow.geometry();
+ if content.size.w <= 0 || content.size.h <= 0 {
+ // No real committed content yet - racing the first commit
+ // right after creation, most likely. Nothing to correct
+ // against, so fall back to the requested rect rather than
+ // collapsing every dimension down to (near) zero.
+ return geom;
+ }
+ // `content` is `xdg_surface::set_window_geometry` - specified to
+ // carry *logical* points, same as `sync_geometry`'s own `size`
+ // going the other direction (see that function's matching doc
+ // comment). Every caller of this method (border, shadow, occlusion,
+ // resize-margin hit-test) works in this compositor's own physical
+ // convention, same as `geom` - so `content.size` needs converting
+ // back to physical here, the same `* scale` `sync_geometry` divides
+ // by on the way out, or a window on a scaled monitor gets a
+ // border/shadow drawn at the *logical* size while its real content
+ // renders at a different *physical* one. On a monitor with
+ // `scale == 1.0` logical and physical are numerically identical, so
+ // this was invisible until this session's own auto-scale feature
+ // gave a monitor a non-1.0 value - reported live as a purple
+ // border sitting visibly detached, to the east and south, from an
+ // undecorated (CSD) window's real content once that happened.
+ let scale = wm.borrow().monitors().iter().find(|m| m.id == w.monitor).map(|m| m.scale).unwrap_or(1.0);
+ let content_physical = ((content.size.w as f64 * scale).round() as i32, (content.size.h as f64 * scale).round() as i32);
+ let band = if w.decorated { TITLEBAR_HEIGHT as i32 } else { 0 };
+ srdwm_core::Rect { x: geom.x, y: geom.y, width: content_physical.0.max(0) as u32, height: (band + content_physical.1.max(0)) as u32 }
+ }
+
+ /// The rect a window's border, shadow, occlusion test, and resize-
+ /// margin hit-test should actually use - `geom` (the requested target,
+ /// or mid-animation the interpolated rect) with its width/height
+ /// replaced by what the client's own surface really committed, when
+ /// that's known and non-degenerate. `x`/`y` are left untouched: the
+ /// top-left corner is already correctly anchored by `content_offset`
+ /// elsewhere (`sync_geometry`/the render loops), only the far edge can
+ /// end up wrong.
+ ///
+ /// `Window.geometry` (what `geom`'s width/height ultimately come from)
+ /// is this compositor's own *request* - what `sync_geometry` asked the
+ /// client to become via `xdg_toplevel::configure`'s `size`. Nothing
+ /// before this ever read back whether the client actually complied.
+ /// Most do, to the pixel - but a client with its own internal size
+ /// quantization (a terminal emulator, snapping its real content to a
+ /// whole number of character cells) can settle on a slightly different
+ /// real size than what was requested, without that being any kind of
+ /// protocol violation. Every caller of this method used to read `geom`
+ /// directly regardless, so the border (and the shadow, and the resize-
+ /// margin hit-test) kept drawing/testing at the *asked-for* edge while
+ /// the client's real content stopped a few pixels short of it --
+ /// reported live as a transparent gap between a terminal's content and
+ /// srdwm's own border, letting the desktop show through underneath.
+ ///
+ /// Niri's own `LayoutElement::size` (`src/window/mapped.rs` in its
+ /// source) is the model this follows: its entire layout - tile size,
+ /// border, focus ring - is driven by `self.window.geometry().size`,
+ /// the client's real, committed value, never by whatever niri itself
+ /// originally requested. This mirrors that for the specific things
+ /// srdwm draws that have to visually hug the real edge. Deliberately
+ /// narrow, not a wholesale switch: `Space` positioning, the
+ /// `xdg_toplevel::configure` math itself, and tiling layout all keep
+ /// reading `Window.geometry` unchanged - those are about this
+ /// compositor's own bookkeeping staying self-consistent, not about
+ /// matching a client's real pixels.
+ pub(crate) fn effective_frame(&self, id: WindowId, geom: srdwm_core::Rect) -> srdwm_core::Rect {
+ Self::effective_frame_of(&self.wm, &self.id_to_window, id, geom)
+ }
+
pub(crate) fn sync_geometry(&mut self, id: WindowId) {
// A pending `anim_from` (set by `toggle_maximize`/`toggle_fullscreen`,
// or by `new_managed_window` for the open-slide) means the target
@@ -28,7 +143,23 @@ impl CompState {
// call for the same window (an ordinary drag/resize frame) goes
// straight back to applying `geometry` immediately, as before.
let anim_from = self.wm.borrow_mut().window_mut(id).and_then(|w| w.anim_from.take());
- let Some((target, decorated, maximized, fullscreen)) = self.wm.borrow().window(id).map(|w| (w.geometry, w.decorated, w.maximized, w.fullscreen)) else { return };
+ let Some((target, decorated, maximized, fullscreen, monitor)) =
+ self.wm.borrow().window(id).map(|w| (w.geometry, w.decorated, w.maximized, w.fullscreen, w.monitor))
+ else {
+ return;
+ };
+ // This compositor's own placement/geometry tracking is physical
+ // pixels throughout (see `Platform::monitors()`'s own doc comment
+ // on that choice); `xdg_toplevel::configure`'s `size` is specified
+ // to carry *logical* points, always, independent of which output a
+ // window is on. Every output was `1.0` before this session's own
+ // auto-scale feature existed, so physical and logical were
+ // numerically identical and this conversion's absence was
+ // invisible. Falls back to `1.0` (no conversion) if this window's
+ // own monitor can't be resolved - the same "assume unscaled
+ // rather than guess" default `MonitorInfo::scale`'s own doc
+ // comment already uses for a disabled output.
+ let scale = self.wm.borrow().monitors().iter().find(|m| m.id == monitor).map(|m| m.scale).unwrap_or(1.0);
if let Some(from) = anim_from {
let duration_ms = self.wm.borrow().animation_duration_ms;
if from != target && duration_ms > 0 {
@@ -51,8 +182,40 @@ impl CompState {
// Position always moves with the pointer; only a size change needs a
// client configure or a titlebar re-render (see `last_synced_size`'s
// doc comment).
- let size = (geom.width as i32, geom.height as i32 - band);
- let size_changed = self.last_synced_size.insert(id, size) != Some(size);
+ //
+ // Converted to logical points here, before anything below reads
+ // `size` - `xdg_toplevel::configure` is specified to carry
+ // logical points, and `w.geometry()` (what the throttle check
+ // below compares a client's real commit against) is a client's own
+ // `xdg_surface::set_window_geometry`, logical by the same
+ // specification - so keeping the rest of this function in that
+ // one space, not switching back to physical partway through, is
+ // what actually keeps every comparison here meaningful.
+ //
+ // This has a real, desirable second effect beyond fixing the unit
+ // mismatch itself: a window that crosses onto a monitor with a
+ // different scale, at the *same* physical size (an ordinary drag
+ // never changes `geom.width`/`geom.height`), now computes a
+ // *different* logical size purely from `scale` changing --
+ // correctly triggering a fresh configure asking the client to
+ // resize to match, the same way real desktop environments keep a
+ // window's true on-screen footprint consistent across a DPI
+ // change. Before this, a plain cross-monitor drag sent no configure
+ // at all (physical size hadn't changed), so the client kept
+ // rendering its old logical size at the new monitor's different
+ // scale while this compositor's own border kept drawing at the
+ // physical rect it always had - reported live as a window's
+ // border ending up visibly detached from its own content after
+ // being dragged to the other monitor.
+ let size_physical = (geom.width as i32, geom.height as i32 - band);
+ let size = ((size_physical.0 as f64 / scale).round() as i32, (size_physical.1 as f64 / scale).round() as i32);
+ // Peeked, not inserted yet - only actually updated once a
+ // configure for `size` is decided below, so a size that keeps
+ // changing tick to tick while throttled (an active drag didn't
+ // stop just because the client hasn't caught up yet) is still
+ // correctly seen as "different from what's actually been sent"
+ // on every later tick, not just the first.
+ let size_changed = self.last_synced_size.get(&id).copied() != Some(size);
let mut moved = false;
if let Some(w) = self.id_to_window.get(&id) {
// `w.geometry().loc` is the client's own `xdg_surface::
@@ -60,25 +223,64 @@ impl CompState {
// concretely) declares its real visible content as a sub-rect
// inset within a larger buffer that also reserves an invisible
// shadow margin, even once the tiled-state hint below has told
- // it to skip drawing that shadow. `render_udev_frame`/
- // `winit/render.rs` both subtract this same offset from where
- // they draw the window's content, specifically so the client's
- // visible content lands at `geom.x, geom.y` instead of a
- // shadow-margin's width/height short of it - `space` has to
- // agree with that adjustment, not just rendering, or every
- // click computed via `win_relative = pos - space_loc` would
- // land `content_offset` short of whatever the user actually
- // clicked on: rendering moves the content, hit-testing keeps
- // routing against where the client's raw, unshifted buffer
- // origin used to be.
- let content_offset = w.geometry().loc;
- self.space.map_element(w.clone(), (geom.x - content_offset.x, geom.y + band - content_offset.y), false);
+ // it to skip drawing that shadow.
+ //
+ // This used to be subtracted from `location` right here, on the
+ // reasoning that `space` needed to be told about it explicitly,
+ // the same way `render_udev_frame`/`winit/render.rs` do for
+ // drawing. That reasoning was wrong about `Space` specifically:
+ // smithay's own `SpaceElement for Window` reports `geometry()`
+ // as `self.geometry()` (this exact `content_offset`, non-zero
+ // `.loc` included), and `Space`'s internal `render_location()`
+ // (what every hit-test - `element_under`, and so `refresh_
+ // pointer_focus`'s `win_relative = pos - loc` - actually reads)
+ // already computes `location - element.geometry().loc` on its
+ // own, unconditionally, for every mapped element. Subtracting
+ // `content_offset` again here meant `Space`'s own tracked
+ // position ended up short by *two* `content_offset`s, not one --
+ // confirmed live via temporary diagnostic logging on both sides:
+ // this call computing a correct, single-subtraction position,
+ // and `Space::element_under` reporting a position exactly one
+ // more `content_offset` short of it for the same window on the
+ // very same commit. The render loops' own manual subtraction is
+ // unaffected and stays - they position elements by hand,
+ // entirely bypassing `Space`'s automatic handling, so they still
+ // have to do this themselves; `xwayland.rs`'s own `map_element`
+ // calls already never did this (X11 windows have no equivalent
+ // shadow-margin geometry), which in hindsight was the correct
+ // pattern being followed there all along.
+ self.space.map_element(w.clone(), (geom.x, geom.y + band), false);
moved = true;
if let Some(top) = w.toplevel() {
// xdg-shell position is a purely compositor-side concept --
// the client is never told it - so only a size change
// needs a configure here.
- if size_changed {
+ //
+ // Throttled to at most one size-changing configure "in
+ // flight" per window, the same way niri does (`window/
+ // mapped.rs`'s `ConfigureIntent::Throttled`) - see
+ // `pending_size_configure`'s own doc comment for why: this
+ // used to send a fresh configure on every single pointer-
+ // motion tick of an active resize regardless of whether the
+ // client had caught up to the *previous* one yet, which a
+ // fast pointer (a real high-poll-rate mouse, niri's own
+ // stated motivation for the same throttle) could easily
+ // outrun into a real backlog. `w.geometry().size` is the
+ // client's actual last-committed content size - once it
+ // matches whatever was last sent, that configure is
+ // considered caught up and the throttle clears on its own,
+ // no separate ack-tracking needed. Bounded by
+ // `CONFIGURE_THROTTLE_TIMEOUT` regardless, so a client that
+ // never catches up for any reason (slow, buggy, wedged)
+ // can't jam resizing shut forever - the same self-healing
+ // shape as this session's own DRM flip-pending watchdog.
+ let throttled = self.pending_size_configure.get(&id).is_some_and(|(pending_size, sent_at)| {
+ let caught_up = w.geometry().size.w == pending_size.0 && w.geometry().size.h == pending_size.1;
+ !caught_up && sent_at.elapsed() < CONFIGURE_THROTTLE_TIMEOUT
+ });
+ if size_changed && !throttled {
+ self.last_synced_size.insert(id, size);
+ self.pending_size_configure.insert(id, (size, Instant::now()));
top.with_pending_state(|state| {
state.size = Some(size.into());
// No configure from this compositor, ever, set any
@@ -167,7 +369,18 @@ impl CompState {
let _ = x11.configure(Rectangle::new((geom.x, geom.y + band).into(), size.into()));
}
}
- if size_changed && self.decorations.contains_key(&id) {
+ // Not gated on `self.decorations.contains_key(&id)` - that map only
+ // ever holds an entry for a *decorated* window (see
+ // `redraw_decoration_buffer`, which only inserts into it when
+ // `w.decorated`), so that gate was permanently false for every
+ // undecorated/CSD window, even one with `border_width > 0`. Its
+ // border bitmaps were rendered once at creation and never rebuilt on
+ // any later resize - reported live as the border "not truly around"
+ // the window after resizing. `redraw_decoration_buffer` already
+ // self-guards via `decoration_signatures` (see its own doc comment),
+ // so calling it unconditionally here costs nothing once the size
+ // genuinely hasn't changed the rasterized output.
+ if size_changed {
self.redraw_decoration_buffer(id);
}
// See `resync_stacking_order`'s doc comment: `map_element` above