srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/state
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
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')
-rw-r--r--crates/wayland/src/state/geometry.rs249
-rw-r--r--crates/wayland/src/state/lifecycle.rs142
-rw-r--r--crates/wayland/src/state/mod.rs151
-rw-r--r--crates/wayland/src/state/tests.rs16
-rw-r--r--crates/wayland/src/state/tick.rs22
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.
///