diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/state/geometry.rs | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-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.rs | 249 |
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 |