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 | |
| 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')
| -rw-r--r-- | crates/wayland/src/state/geometry.rs | 249 | ||||
| -rw-r--r-- | crates/wayland/src/state/lifecycle.rs | 142 | ||||
| -rw-r--r-- | crates/wayland/src/state/mod.rs | 151 | ||||
| -rw-r--r-- | crates/wayland/src/state/tests.rs | 16 | ||||
| -rw-r--r-- | crates/wayland/src/state/tick.rs | 22 |
5 files changed, 529 insertions, 51 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 diff --git a/crates/wayland/src/state/lifecycle.rs b/crates/wayland/src/state/lifecycle.rs index db2de44..259979b 100644 --- a/crates/wayland/src/state/lifecycle.rs +++ b/crates/wayland/src/state/lifecycle.rs @@ -74,6 +74,45 @@ impl CompState { let Some(w) = self.wm.borrow().window(id).cloned() else { return }; let focused = self.wm.borrow().focused_id() == Some(id); let theme = self.wm.borrow().theme; + // Read fresh every call, not cached from creation - a client can + // call `xdg_toplevel.set_parent` well after its own initial map + // (a "Save As" dialog opened from an already-open main window, + // say), and this function already re-runs on every relevant state + // change. Written back onto the real `Window` (not just used + // locally) so `ResizeEdge::hit_test`'s own `is_dialog` parameter + // - read from `core`, which has no protocol concept to derive + // this from itself - agrees with whatever got drawn here. An + // XWayland window's own `WM_TRANSIENT_FOR` isn't read yet, so this + // stays `false` for those specifically - see `Window::is_dialog`'s + // own doc comment. + let is_dialog = self.id_to_window.get(&id).and_then(|dw| dw.toplevel()).map(|t| t.parent().is_some()).unwrap_or(false); + if let Some(win) = self.wm.borrow_mut().window_mut(id) { + win.is_dialog = is_dialog; + } + // Corrects `w.geometry`'s far edge to match what the client's + // surface really committed, when that's known - see + // `effective_frame`'s own doc comment. Every bitmap this method + // builds (titlebar, top/bottom border, shadow) is sized from + // `frame`, not `w.geometry` directly, so a client that settles on + // a slightly different real size than requested (a terminal + // snapping to a whole number of character cells, most commonly) + // gets decoration that actually hugs its real edge instead of the + // asked-for one. + let frame = self.effective_frame(id, w.geometry); + // Eased (ease-out-cubic, same curve `WindowAnim::current_rect` + // already uses - see that doc comment) progress of the glyph- + // reveal-on-hover animation, discretized to a `u8` alpha. `theme. + // button_glyph_always` skips the timing/easing math entirely and + // just asks for full opacity outright - see `render_titlebar`'s + // own `glyph_always` parameter for where that's actually applied + // (it overrides this per-button, not just here). + let hovered_button = self.hovered_titlebar_button.and_then(|(hid, hit, start)| { + (hid == id).then(|| { + let t = (start.elapsed().as_secs_f32() / decoration::HOVER_GLYPH_DURATION.as_secs_f32()).min(1.0); + let eased = 1.0 - (1.0 - t).powi(3); + (hit, (eased * 255.0).round() as u8) + }) + }); // `main.rs`'s `sync()` calls `Platform::redraw_decoration` - which // always reaches here - for every visible window on every dirty // tick, not only the window that actually changed (see `Comp @@ -85,8 +124,8 @@ impl CompState { // call turns those redundant calls into a cheap signature // comparison instead of a full re-rasterization. let signature = DecorationSignature { - width: w.geometry.width, - height: w.geometry.height, + width: frame.width, + height: frame.height, decorated: w.decorated, focused, title: w.title.clone(), @@ -96,6 +135,13 @@ impl CompState { maximized: w.maximized, fullscreen: w.fullscreen, shadows_enabled: self.wm.borrow().shadows_enabled, + hovered_button, + title_centered: theme.title_centered, + buttons_left: theme.buttons_left, + button_glyph_always: theme.button_glyph_always, + button_order: theme.button_order, + traffic_light_buttons: theme.traffic_light_buttons, + is_dialog, }; if self.decoration_signatures.get(&id) == Some(&signature) { return; @@ -103,13 +149,30 @@ impl CompState { self.decoration_signatures.insert(id, signature); if w.decorated { let fg = if focused { theme.titlebar_fg_focused } else { theme.titlebar_fg_unfocused }; - let width = w.geometry.width.max(1); + let width = frame.width.max(1); // Always rounded now, bordered or not - `render_border_top` // gives a bordered window's border strip the matching rounded // cut, so there's no more square-frame-around-a-round-titlebar // clash to avoid. See `render_titlebar`'s `round_corners` doc // comment. - let data = decoration::render_titlebar(width, TITLEBAR_HEIGHT, &w.title, theme.titlebar_bg, fg, true, w.corner_radius); + let data = decoration::render_titlebar( + width, + TITLEBAR_HEIGHT, + &w.title, + theme.titlebar_bg, + fg, + true, + w.corner_radius, + w.border_width, + focused, + hovered_button, + theme.title_centered, + theme.buttons_left, + theme.button_glyph_always, + theme.button_order, + theme.traffic_light_buttons, + is_dialog, + ); let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None); self.decorations.insert(id, buffer); } else { @@ -126,20 +189,54 @@ impl CompState { // this every render frame (an earlier version of this method did) // was a real, continuous cost, not just a redundant one. if w.border_width > 0 { - let color = effective_border_color(w.border_color, focused); - let strips = decoration::border_strips(w.geometry, w.border_width); + let color = effective_border_color(w.border_color, focused, theme.border_inactive_dim); + let strips = decoration::border_strips(frame, w.border_width); + // `render_border_top`/`render_border_bottom` both return a + // buffer `border_width.max(corner_radius)` rows tall now, not + // always exactly `border_width` - see their own doc comments + // for why a strip thinner than the corner radius needs the + // extra rows to let the curve actually resolve before handing + // off to the (curve-blind) side strips. `render.rs`'s call + // site positions this taller buffer to match: the top strip + // grows downward from its existing anchor (unchanged), the + // bottom strip grows upward, so its anchor shifts up by + // exactly the extra height. + let strip_h = w.border_width.max(w.corner_radius); if strips[0].width > 0 && strips[0].height > 0 { let data = decoration::render_border_top(strips[0].width, w.border_width, color, w.corner_radius); - let buffer = - MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[0].width as i32, w.border_width as i32), 1, Transform::Normal, None); + let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[0].width as i32, strip_h as i32), 1, Transform::Normal, None); self.border_top_decorations.insert(id, buffer); } else { self.border_top_decorations.remove(&id); } if strips[1].width > 0 && strips[1].height > 0 { let data = decoration::render_border_bottom(strips[1].width, w.border_width, color, w.corner_radius); - let buffer = - MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[1].width as i32, w.border_width as i32), 1, Transform::Normal, None); + // Temporary: chasing a live report that the bottom two + // corners render as a solid, uncurved block for the buffer's + // own "extra" rows (0..height-thickness) while the nominal + // rows (height-thickness..height) curve correctly. Dumps the + // alpha byte at x=0..11 for row 0 (should already show some + // cutting per a standalone simulation of this exact + // algorithm) and the last nominal row, straight out of the + // buffer this function just built - before it's wrapped + // into a MemoryRenderBuffer at all, so this is ground truth + // for whether `render_border_bottom` itself is the problem + // or something downstream of it is. Remove once resolved. + let w_usize = strips[1].width.max(1) as usize; + let h_usize = strip_h.max(1) as usize; + let alpha_row = |row: usize| -> Vec<u8> { + (0..12.min(w_usize)).map(|x| data.get((row * w_usize + x) * 4 + 3).copied().unwrap_or(255)).collect() + }; + log::debug!( + "udev::lifecycle: BOTTOM border buffer for {} (id {id:?}): dims={w_usize}x{h_usize} row0_alpha={:?} row3_alpha={:?} row7_alpha={:?} row8_alpha={:?} row11_alpha={:?}", + w.app_id, + alpha_row(0), + alpha_row(3.min(h_usize.saturating_sub(1))), + alpha_row(7.min(h_usize.saturating_sub(1))), + alpha_row(8.min(h_usize.saturating_sub(1))), + alpha_row(11.min(h_usize.saturating_sub(1))), + ); + let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[1].width as i32, strip_h as i32), 1, Transform::Normal, None); self.border_bottom_decorations.insert(id, buffer); } else { self.border_bottom_decorations.remove(&id); @@ -158,8 +255,29 @@ impl CompState { // against. let shadows_enabled = self.wm.borrow().shadows_enabled; if shadows_enabled && !w.maximized && !w.fullscreen { - let data = decoration::shadow_bitmap(w.geometry.width, w.geometry.height); - let rect = decoration::shadow_rect(w.geometry); + // A decorated window's corners are *always* rounded (the + // titlebar/border strips round to `corner_radius` regardless of + // this setting - see their own call sites); an undecorated + // (CSD) window's own content only gets rounded when `general. + // rounded_corners` is on (default off on this backend - see + // `WindowManager::rounded_corners_enabled`'s doc comment). The + // shadow has to match whichever is actually true for *this* + // window, or it mismatches in the other direction: a rounded + // shadow around a still-square undecorated window with content + // rounding off. + let rounded_corners_enabled = self.wm.borrow().rounded_corners_enabled.unwrap_or(false); + let shadow_radius = if w.decorated || rounded_corners_enabled { w.corner_radius } else { 0 }; + // Dimmed the same way `effective_border_color` dims an + // unfocused window's border - see `shadow_bitmap`'s own + // `max_alpha` doc comment for the real-desktop convention this + // matches (Hyprland's `color`/`color_inactive` shadow split). + let max_alpha = if focused { + decoration::SHADOW_MAX_ALPHA + } else { + (decoration::SHADOW_MAX_ALPHA as f32 * theme.border_inactive_dim).round().clamp(0.0, 255.0) as u8 + }; + let data = decoration::shadow_bitmap(frame.width, frame.height, shadow_radius, max_alpha); + let rect = decoration::shadow_rect(frame); let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (rect.width as i32, rect.height as i32), 1, Transform::Normal, None); self.shadow_buffers.insert(id, buffer); } else { diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs index 084e407..e315b2e 100644 --- a/crates/wayland/src/state/mod.rs +++ b/crates/wayland/src/state/mod.rs @@ -113,6 +113,44 @@ pub(crate) struct DecorationSignature { pub(crate) maximized: bool, pub(crate) fullscreen: bool, pub(crate) shadows_enabled: bool, + /// Which of *this* window's own titlebar buttons (if any) is currently + /// hovered, and the glyph-reveal animation's current progress (0..=255) + /// - see `CompState::hovered_titlebar_button`'s own doc comment. + /// Included here, progress and all, so hovering (or un-hovering) a + /// button - and every intermediate frame of the reveal animating in + /// between - is a real signature change, not silently absorbed by the + /// cache this struct exists to drive; a signature that only recorded + /// *which* button was hovered, not the animation's own progress, would + /// cache the very first frame of the reveal and never rebuild again + /// for the rest of it. + pub(crate) hovered_button: Option<(srdwm_core::TitlebarHit, u8)>, + /// `theme.title_centered` at the time this was rendered - a live + /// `srd`-side theme change (there's no `srd set` for this yet, but + /// nothing here assumes there never will be) must still invalidate the + /// cache like every other themed input already does. + pub(crate) title_centered: bool, + /// `theme.buttons_left` at render time - same reasoning as `title_ + /// centered` above. + pub(crate) buttons_left: bool, + /// `theme.button_glyph_always`/`theme.button_order`/`theme. + /// traffic_light_buttons` at render time - same reasoning as `title_ + /// centered`/`buttons_left` above (no `srd set` for any of the three + /// yet either), and the same real gap those two fields were added to + /// close: all three are passed straight into `render_titlebar` + /// (`redraw_decoration_buffer`'s own call site) but were missing from + /// this struct entirely until a full-pipeline audit found the mismatch + /// - a live change to any of the three would have compared equal + /// against a stale signature and silently never rebuilt the titlebar + /// this window already has cached. + pub(crate) button_glyph_always: bool, + pub(crate) button_order: Option<srdwm_core::ButtonOrder>, + pub(crate) traffic_light_buttons: bool, + /// `Window::is_dialog` at render time - a client can call `xdg_ + /// toplevel.set_parent` well after its own initial map (a "Save As" + /// dialog opened from an already-open main window, say), so this needs + /// the same cache-invalidation treatment as every other live- + /// changeable input here, not just a value read once at creation. + pub(crate) is_dialog: bool, } /// Everything smithay's protocol handlers need `&mut` access to. This is the @@ -190,6 +228,23 @@ pub(crate) struct CompState { /// menu` rather than instead of it - they cover disjoint sets of /// windows (XWayland-backed vs. Wayland-native), not the same one. pub(crate) _appmenu_state: appmenu::AppmenuManagerState, + /// `zwp_virtual_keyboard_manager_v1` - lets a client (`wtype`, `ydotool + /// type`, an accessibility tool, AGS's own global-menu shortcut items) + /// inject synthetic key events through the exact same keyboard-focus/ + /// keymap pipeline a real key press already goes through, rather than + /// needing a compositor-specific IPC of its own. Not `Option`-gated, + /// same reasoning as `_appmenu_state` just above: injecting a key event + /// has nothing GPU/DRM-specific about it either. Smithay's own + /// `wayland::virtual_keyboard` module provides the full protocol + /// implementation (`delegate_virtual_keyboard_manager!` in + /// `protocols.rs` wires it up); this compositor only supplies the + /// global itself. Absence of this was reported live as "most options in + /// global menu don't work" - every keyboard-shortcut item there is + /// delivered via `wtype`, which silently does nothing at all without + /// this protocol (`wtype ""` exits 1 with "Compositor does not support + /// the virtual keyboard protocol"), a failure the caller (AGS, fire- + /// and-forget) never even saw. + pub(crate) _virtual_keyboard_state: smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState, pub(crate) _foreign_toplevel_state: foreign_toplevel::ForeignToplevelState, /// Every bound `zwlr_foreign_toplevel_manager_v1` (one per dock/switcher /// client), so a newly-created window can be announced to all of them -- @@ -238,6 +293,15 @@ pub(crate) struct CompState { /// the default when no client has said). See `cursor.rs` for why this /// has to be drawn by us on the DRM backend. pub(crate) cursor_status: smithay::input::pointer::CursorImageStatus, + /// Whether `cursor_status`'s current value was last set by us (hovering + /// our own decoration's resize edge/drag area) rather than by a client's + /// own `wl_pointer.set_cursor` request - see `input.rs::update_cursor_ + /// shape`'s doc comment for the bug this exists to fix: without it, a + /// resize icon forced while hovering a decoration edge stayed on screen + /// indefinitely once the pointer moved onto plain client content, + /// because nothing about moving onto content gives the client any + /// reason to call `set_cursor` again itself. + pub(crate) decoration_cursor_active: bool, /// Built-in cursor bitmaps (arrow, text, resize directions), built once /// at startup rather than per frame. pub(crate) cursor_buffers: crate::cursor::CursorBuffers, @@ -353,6 +417,22 @@ pub(crate) struct CompState { /// own blanket call, which never actually checked whether this /// specific window was one of the windows that triggered the tick. pub(crate) decoration_signatures: HashMap<WindowId, DecorationSignature>, + /// Which titlebar button (if any) the pointer is currently over, on + /// which window, and *when that hover started* - set from `handle_ + /// pointer_position`'s own `hit_test` result, read by `redraw_ + /// decoration_buffer` to brighten that one button's dot and animate + /// its glyph in (see `decoration::render_titlebar`'s `hovered` + /// parameter). Explicitly requested background-highlight-on-hover + /// behaviour for the titlebar buttons, previously unimplemented - see + /// `docs/TODO.md`. A single `Option`, not a per-window map: only one + /// button can plausibly be hovered at a time, across every window. + /// The `Instant` is *only* updated when the hovered button itself + /// changes (see the comparison at its own call site, which ignores + /// this field) - it marks "hover started here", not "last motion + /// event", so `tick_hover_glyph_animation` can measure real elapsed + /// hover time instead of resetting every frame the pointer so much as + /// twitches while still over the same button. + pub(crate) hovered_titlebar_button: Option<(WindowId, srdwm_core::TitlebarHit, Instant)>, /// A window's drop-shadow bitmap (`decoration::shadow_bitmap`), cached /// the same way and at the same trigger points as `border_top_decorations` /// - rebuilt only on creation or a real size change, not per frame, for @@ -384,19 +464,24 @@ pub(crate) struct CompState { /// terminal, with nothing else in this struct tracking that. pub(crate) content_epoch: HashMap<WindowId, u64>, /// The udev/Pixman-backend rounded-corner masked copy of a window's own - /// content (`rounded_corners_pixman::masked_content_buffer`), paired - /// with the `content_epoch` value and the `corner_radius` (in bit-cast - /// `u32` form - `f32` has no `Eq`) it was built from - see - /// `elements::rounded_content_buffer`, which owns rebuilding this. The - /// radius half exists because `corner_radius` is now live-settable - /// (`srd set corner_radius`/a rule) without any client commit - content - /// epoch alone wouldn't notice that change, leaving a stale mask built - /// from the old radius on screen until the client's next real repaint. - /// Always empty on the winit backend (GLES rounds via a shader instead, - /// `rounded_corners_program`), but costs nothing to declare here - /// unconditionally, the same call `rounded_corners_program` itself - /// already makes. - pub(crate) rounded_content_buffers: HashMap<WindowId, (u64, u32, MemoryRenderBuffer)>, + /// content (`rounded_corners_pixman::masked_content_buffer`), keyed by + /// everything that can make a rebuilt-from-scratch copy necessary -- + /// see `elements::rounded_content_buffer`, which owns rebuilding this. + /// In order: the `content_epoch` value it was built from (bumped once + /// per real client commit); the `corner_radius` it was built from, in + /// bit-cast `u32` form (`f32` has no `Eq`) - live-settable (`srd set + /// corner_radius`/a rule) without any client commit, so `content_epoch` + /// alone wouldn't notice a change; the tree-render `loc` it was built + /// from (the negated `content_offset`, changes if a client alters its + /// own declared shadow-margin geometry); and the off-screen buffer + /// `size` it was built at (the window's own content dimensions -- + /// stale the moment those change, same reason `redraw_decoration_ + /// buffer`'s own signature check exists for the titlebar/border + /// bitmaps). Always empty on the winit backend (GLES rounds via a + /// shader instead, `rounded_corners_program`), but costs nothing to + /// declare here unconditionally, the same call `rounded_corners_ + /// program` itself already makes. + pub(crate) rounded_content_buffers: HashMap<WindowId, (u64, u32, (i32, i32), (i32, i32), MemoryRenderBuffer)>, /// Persistent solid-colour buffers backing a window's other three /// border strips (bottom, left, right - `decoration::border_strips`' /// order past index 0), reused by position every frame rather than @@ -410,8 +495,15 @@ pub(crate) struct CompState { /// dropping them would lose the damage-tracking stability the whole /// scheme exists for the moment fragment counts fluctuate back up. pub(crate) border_side_buffers: HashMap<WindowId, Vec<SolidColorBuffer>>, - /// Client-visible size (`geometry` minus the titlebar band) last sent to - /// each window via `xdg_toplevel.configure`. `sync_geometry` runs on + /// Persistent solid-colour buffer backing the whole-output night-light/ + /// reading-mode overlay, one per output name - same "reuse the buffer + /// so its `Id` stays stable across frames" reasoning as `border_side_ + /// buffers` above. See `color_filter::render_element`. + pub(crate) color_filter_buffers: HashMap<String, SolidColorBuffer>, + /// Client-visible size (`geometry` minus the titlebar band, converted + /// to logical points for whichever monitor the window is currently on + /// - see `sync_geometry`'s own doc comment) last sent to each window + /// via `xdg_toplevel.configure`. `sync_geometry` runs on /// every pointer-motion tick while a window is being dragged or resized /// (see `input::handle_pointer_position`); a plain move changes only /// position, not size, so without this it was re-sending a configure @@ -419,6 +511,28 @@ pub(crate) struct CompState { /// motion event of every drag, which is what made moving a window /// stutter. Only a real size change now does either. pub(crate) last_synced_size: HashMap<WindowId, (i32, i32)>, + /// A size-changing `xdg_toplevel.configure` that's been sent but not + /// yet reflected in the client's own real committed content size -- + /// `(size requested, when it was sent)`. `sync_geometry` won't send + /// *another* size-changing configure for the same window while an + /// entry is still here (unless `CONFIGURE_THROTTLE_TIMEOUT` has + /// elapsed - see that constant's own doc comment for why this can + /// never wedge resize entirely). + /// + /// Niri throttles the same way (`window/mapped.rs`'s `ConfigureIntent:: + /// Throttled`, keyed on the configure serial rather than a size/time + /// pair, but the same idea) - its own comment: "some clients do not + /// batch size requests, leading to bad behavior with very fast input + /// devices... this throttling also helps interactive resize + /// transactions preserve visual consistency." srdwm had no equivalent + /// at all: `sync_geometry` runs on every pointer-motion tick of an + /// active resize and only ever compared the newly-requested size + /// against the *previous request*, never against what the client had + /// actually caught up to - a fast drag (a real high-poll-rate mouse, + /// confirmed as niri's own stated motivation) could queue several + /// configures before the client acknowledged the first, the exact + /// backlog this field exists to prevent. + pub(crate) pending_size_configure: HashMap<WindowId, ((i32, i32), Instant)>, pub(crate) pending: Rc<RefCell<Vec<CoreEvent>>>, pub(crate) bound_keys: Rc<HashSet<String>>, /// Combos that repeat while held (`srd.bind_repeat`). @@ -650,13 +764,12 @@ impl CompState { /// `border_color` is a real, used feature (rules set distinct colours per /// app), and dimming keeps that distinction visible at a glance while /// still making focus unambiguous. -pub(crate) fn effective_border_color(configured: (u8, u8, u8), focused: bool) -> (u8, u8, u8) { +pub(crate) fn effective_border_color(configured: (u8, u8, u8), focused: bool, dim: f32) -> (u8, u8, u8) { if focused { return configured; } - const DIM: f32 = 0.35; - let dim = |c: u8| (c as f32 * DIM) as u8; - (dim(configured.0), dim(configured.1), dim(configured.2)) + let scale = |c: u8| (c as f32 * dim).round().clamp(0.0, 255.0) as u8; + (scale(configured.0), scale(configured.1), scale(configured.2)) } /// Output lookup. Everything that used to reach for a single diff --git a/crates/wayland/src/state/tests.rs b/crates/wayland/src/state/tests.rs index bccbae2..8d1d8b3 100644 --- a/crates/wayland/src/state/tests.rs +++ b/crates/wayland/src/state/tests.rs @@ -2,12 +2,15 @@ #[test] fn focused_window_keeps_its_configured_colour() { - assert_eq!(effective_border_color((136, 192, 208), true), (136, 192, 208)); + // The dim factor is irrelevant when focused - passing an + // obviously-wrong one here doubles as proof the early return never + // even looks at it. + assert_eq!(effective_border_color((136, 192, 208), true, 0.0), (136, 192, 208)); } #[test] fn unfocused_window_is_dimmed_but_still_recognisably_that_colour() { - let dimmed = effective_border_color((136, 192, 208), false); + let dimmed = effective_border_color((136, 192, 208), false, 0.35); // Dimmer in every channel... assert!(dimmed.0 < 136 && dimmed.1 < 192 && dimmed.2 < 208); // ...but not black, and the channels' relative order is preserved @@ -19,6 +22,15 @@ } #[test] + fn inactive_dim_factor_is_actually_configurable() { + // `theme.decorations.border.inactive_dim` - `1.0` keeps an + // unfocused border identical to focused, `0.0` removes it entirely + // (fully black, matching every channel scaled to zero). + assert_eq!(effective_border_color((136, 192, 208), false, 1.0), (136, 192, 208)); + assert_eq!(effective_border_color((136, 192, 208), false, 0.0), (0, 0, 0)); + } + + #[test] fn window_anim_starts_at_from_and_ends_at_to() { let anim = WindowAnim { from: srdwm_core::Rect::new(0, 100, 300, 200), diff --git a/crates/wayland/src/state/tick.rs b/crates/wayland/src/state/tick.rs index 6ef0ed6..333c30b 100644 --- a/crates/wayland/src/state/tick.rs +++ b/crates/wayland/src/state/tick.rs @@ -34,6 +34,28 @@ impl CompState { } } + /// Forces a fresh `redraw_decoration_buffer` call every frame while the + /// titlebar-button glyph-reveal-on-hover animation is still in + /// progress; called once per redraw from both backends' poll loops, + /// alongside `tick_animations`. Needed for the same reason that one + /// is: `redraw_decoration_buffer`'s own signature-based cache only + /// rebuilds when *called*, and nothing else calls it once a pointer + /// stops moving over an already-hovered button - without this, the + /// glyph would jump straight from invisible to full opacity on the + /// one motion event that started the hover, then never update again + /// for the rest of the animation's own duration, since no further + /// motion event arrives to drive it. Does nothing in `theme. + /// button_glyph_always` mode or once the animation has actually + /// finished (`HOVER_GLYPH_DURATION` elapsed) - both are already a + /// stable, cached final state with nothing left to advance. + pub(crate) fn tick_hover_glyph_animation(&mut self) { + let Some((id, _, start)) = self.hovered_titlebar_button else { return }; + if self.wm.borrow().theme.button_glyph_always || start.elapsed() >= decoration::HOVER_GLYPH_DURATION { + return; + } + self.redraw_decoration_buffer(id); + } + /// Re-applies `WindowManager`'s own stacking order to `Space`, bottom /// to top. /// |