srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland
diff options
context:
space:
mode:
Diffstat (limited to 'crates/wayland')
-rw-r--r--crates/wayland/src/color_filter.rs98
-rw-r--r--crates/wayland/src/decoration.rs858
-rw-r--r--crates/wayland/src/decoration/border.rs184
-rw-r--r--crates/wayland/src/decoration/buttons.rs291
-rw-r--r--crates/wayland/src/decoration/color.rs34
-rw-r--r--crates/wayland/src/decoration/corners.rs165
-rw-r--r--crates/wayland/src/decoration/font.rs146
-rw-r--r--crates/wayland/src/decoration/shadow.rs186
-rw-r--r--crates/wayland/src/decoration/tests.rs756
-rw-r--r--crates/wayland/src/decoration/titlebar.rs295
-rw-r--r--crates/wayland/src/elements.rs109
-rw-r--r--crates/wayland/src/input.rs1012
-rw-r--r--crates/wayland/src/input/focus.rs138
-rw-r--r--crates/wayland/src/input/gestures.rs131
-rw-r--r--crates/wayland/src/input/keyboard.rs174
-rw-r--r--crates/wayland/src/input/layers.rs169
-rw-r--r--crates/wayland/src/input/pointer.rs683
-rw-r--r--crates/wayland/src/lib.rs10
-rw-r--r--crates/wayland/src/monitor_layout.rs153
-rw-r--r--crates/wayland/src/native_lock.rs38
-rw-r--r--crates/wayland/src/output_management.rs49
-rw-r--r--crates/wayland/src/protocols.rs819
-rw-r--r--crates/wayland/src/protocols/buffer.rs68
-rw-r--r--crates/wayland/src/protocols/compositor.rs182
-rw-r--r--crates/wayland/src/protocols/idle.rs35
-rw-r--r--crates/wayland/src/protocols/input_method.rs86
-rw-r--r--crates/wayland/src/protocols/layer_shell.rs117
-rw-r--r--crates/wayland/src/protocols/misc.rs30
-rw-r--r--crates/wayland/src/protocols/seat.rs31
-rw-r--r--crates/wayland/src/protocols/selection.rs52
-rw-r--r--crates/wayland/src/protocols/xdg_activation.rs36
-rw-r--r--crates/wayland/src/protocols/xdg_decoration.rs63
-rw-r--r--crates/wayland/src/protocols/xdg_shell.rs258
-rw-r--r--crates/wayland/src/rounded_corners_pixman.rs205
-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
-rw-r--r--crates/wayland/src/udev/capture.rs53
-rw-r--r--crates/wayland/src/udev/drm.rs49
-rw-r--r--crates/wayland/src/udev/mod.rs294
-rw-r--r--crates/wayland/src/udev/outputs.rs295
-rw-r--r--crates/wayland/src/udev/platform.rs316
-rw-r--r--crates/wayland/src/udev/render.rs516
-rw-r--r--crates/wayland/src/udev/session.rs49
-rw-r--r--crates/wayland/src/winit/capture.rs17
-rw-r--r--crates/wayland/src/winit/connect.rs5
-rw-r--r--crates/wayland/src/winit/mod.rs3
-rw-r--r--crates/wayland/src/winit/nested_platform.rs (renamed from crates/wayland/src/winit/platform.rs)8
-rw-r--r--crates/wayland/src/winit/render.rs147
-rw-r--r--crates/wayland/src/xwayland.rs120
52 files changed, 7121 insertions, 2992 deletions
diff --git a/crates/wayland/src/color_filter.rs b/crates/wayland/src/color_filter.rs
new file mode 100644
index 0000000..f9a259e
--- /dev/null
+++ b/crates/wayland/src/color_filter.rs
@@ -0,0 +1,98 @@
+//! Whole-screen colour treatments (`srd set night_light`/`srd set
+//! reading_mode`), ported from a Hyprland setup that pointed its
+//! `decoration:screen_shader` at a small GLSL fragment shader - one that
+//! multiplied every pixel by a warm tint, the other that flattened every
+//! pixel to its own luminance.
+//!
+//! Neither backend has an equivalent hook: the udev backend's
+//! `PixmanRenderer` is software-only (see `blur.rs`'s own doc comment),
+//! and reproducing the effect faithfully even on the GLES/winit backend
+//! would mean a full-frame capture + per-pixel transform + re-import on
+//! every damaged frame - the same per-frame CPU cost this codebase has
+//! already measured and rejected once for something far smaller (see
+//! `rounded_corners_pixman`'s module doc comment, and `udev/render.rs`'s
+//! own note on why that backend defaults corner-rounding off: "a full
+//! row-by-row buffer copy on every commit of a constantly-repainting
+//! client", named video specifically as the cost case that mattered).
+//! A whole-output tint is that same cost applied to *every* pixel of
+//! *every* frame, not just a window's corners.
+//!
+//! Instead, both effects are approximated with a single translucent
+//! `SolidColorRenderElement` covering the output, alpha-blended over the
+//! real scene by the renderer's native (and therefore free) `Frame::
+//! draw_solid` - no readback, no per-pixel work, no texture import.
+//! Blending any colour with a fixed colour is mathematically a pull
+//! toward that colour on every channel, which is close enough to both
+//! source shaders' actual intent (night light: pull blue/green down more
+//! than red; reading mode: pull every channel toward flat gray) to read
+//! as the same effect, at a cost indistinguishable from one extra
+//! ordinary border strip.
+
+use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
+use smithay::backend::renderer::element::Kind;
+use smithay::backend::renderer::Color32F;
+use smithay::utils::Point;
+
+use srdwm_core::ColorFilter;
+
+/// `(r, g, b, a)`, each `0.0..=1.0`, for the given filter - `None` for
+/// [`ColorFilter::None`], meaning "draw nothing".
+///
+/// Night light's warm colour (255, 166, 87) and reading mode's neutral
+/// gray (128, 128, 128) are standard picks for this exact overlay trick
+/// (the same warm RGB triple Redshift/f.lux converge on for a low colour
+/// temperature); the alphas were picked by eye against the ported
+/// shaders' own strength - night light stays subtle (it runs for hours),
+/// reading mode is deliberately stronger (it is opted into for a single
+/// focused task).
+fn overlay_rgba(filter: ColorFilter) -> Option<(f32, f32, f32, f32)> {
+ match filter {
+ ColorFilter::None => None,
+ ColorFilter::NightLight => Some((1.0, 0.65, 0.34, 0.35)),
+ ColorFilter::ReadingMode => Some((0.5, 0.5, 0.5, 0.55)),
+ }
+}
+
+/// Builds the full-output overlay element for `filter`, or `None` for
+/// [`ColorFilter::None`] (nothing to draw). `buf` must be a *persistent*
+/// buffer kept one-per-output across frames, exactly like `elements::
+/// border_side_render_element`'s own `buf` parameter - a fresh
+/// `SolidColorBuffer` every frame gets a fresh `Id`, which defeats the
+/// damage tracker's element cache and marks the whole output damaged
+/// forever (see that function's doc comment for the full mechanism, and
+/// why border strips themselves used to have this exact bug).
+pub(crate) fn render_element(buf: &mut SolidColorBuffer, filter: ColorFilter, size: (i32, i32)) -> Option<SolidColorRenderElement> {
+ let (r, g, b, a) = overlay_rgba(filter)?;
+ buf.update(size, Color32F::new(r, g, b, a));
+ Some(SolidColorRenderElement::from_buffer(buf, Point::from((0, 0)), 1.0, 1.0, Kind::Unspecified))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn none_draws_nothing() {
+ assert_eq!(overlay_rgba(ColorFilter::None), None);
+ }
+
+ #[test]
+ fn night_light_pulls_blue_down_the_most_and_red_the_least() {
+ let (r, g, b, _) = overlay_rgba(ColorFilter::NightLight).unwrap();
+ assert!(r > g && g > b, "warm tint should redden more than it greens, and green more than it blues");
+ }
+
+ #[test]
+ fn reading_mode_is_neutral_gray() {
+ let (r, g, b, _) = overlay_rgba(ColorFilter::ReadingMode).unwrap();
+ assert_eq!((r, g, b), (0.5, 0.5, 0.5));
+ }
+
+ #[test]
+ fn every_variant_alpha_is_a_real_blend_not_opaque_or_invisible() {
+ for filter in [ColorFilter::NightLight, ColorFilter::ReadingMode] {
+ let (.., a) = overlay_rgba(filter).unwrap();
+ assert!(a > 0.0 && a < 1.0, "{filter:?}'s overlay must still let the real scene show through");
+ }
+ }
+}
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs
index 36915e5..98bb9d1 100644
--- a/crates/wayland/src/decoration.rs
+++ b/crates/wayland/src/decoration.rs
@@ -1,97 +1,71 @@
-//! Software rasterization of the titlebar band: solid background + drawn
-//! title text, as a BGRA8 pixel buffer (the byte order `Fourcc::Argb8888`
-//! expects when uploaded via `smithay`'s `GlesRenderer`, see
-//! `format::gl_internal_format` - it maps to `GL_BGRA_EXT`/`GL_UNSIGNED_BYTE`).
+//! Software rasterization of every hand-drawn piece of window chrome this
+//! compositor's own CPU/Pixman render path draws itself: the titlebar band
+//! (background, title text, button cluster), the border strips, the drop
+//! shadow, and two small standalone popups (the titlebar right-click menu,
+//! the Snap-Layouts flyout) - all as BGRA8 pixel buffers (the byte order
+//! `Fourcc::Argb8888` expects when uploaded via `smithay`'s `GlesRenderer`,
+//! see `format::gl_internal_format` - it maps to `GL_BGRA_EXT`/
+//! `GL_UNSIGNED_BYTE`).
//!
-//! Deliberately has zero `smithay` dependency: it's a pure `(width, height,
-//! text) -> Vec<u8>` function, unit-testable without a GL context or
-//! display, with a thin adapter in `lib.rs` uploading the result into a
-//! `MemoryRenderBuffer`.
-
-use fontdue::{Font, FontSettings};
-use std::sync::OnceLock;
-
-pub(crate) const FONT_PIXELS: f32 = 13.0;
-pub(crate) const TEXT_LEFT_PADDING: f32 = 8.0;
-
-/// Titlebar buttons are laid out right-aligned in `height`-wide squares --
-/// matching `ResizeEdge::hit_test` in `crates/core/src/window.rs`, whose
-/// `BUTTON` constant is also `TITLEBAR_HEIGHT`. That function only computes
-/// *where* a click on close/maximize/minimize lands; nothing painted the
-/// buttons themselves, so the whole band was one undifferentiated bar with
-/// no visible way to tell where those three clickable regions were.
-const BUTTON_MARGIN: f32 = 0.32;
-
-/// Common monospace font file locations on Linux desktops. Not a full
-/// fontconfig query (no new system dependency for something this small) --
-/// if none of these resolve, titlebars fall back to solid-color-only, same
-/// as before text rendering existed.
-pub(crate) fn find_system_font() -> Option<Font> {
- static FONT: OnceLock<Option<Font>> = OnceLock::new();
- FONT.get_or_init(load_any_monospace_font).clone()
-}
-
-fn load_any_monospace_font() -> Option<Font> {
- let roots = ["/usr/share/fonts", "/usr/local/share/fonts"];
- let mut home_roots = Vec::new();
- if let Ok(home) = std::env::var("HOME") {
- home_roots.push(format!("{home}/.local/share/fonts"));
- home_roots.push(format!("{home}/.fonts"));
- }
- let all_roots = roots.iter().map(|s| s.to_string()).chain(home_roots);
-
- let mut best: Option<std::path::PathBuf> = None;
- for root in all_roots {
- find_ttf_preferring_mono(std::path::Path::new(&root), &mut best);
- if best.is_some() {
- break;
- }
- }
- let path = best?;
- let bytes = std::fs::read(&path).ok()?;
- match Font::from_bytes(bytes, FontSettings::default()) {
- Ok(f) => {
- log::info!("wayland titlebar font: {}", path.display());
- Some(f)
- }
- Err(e) => {
- log::warn!("failed to parse font {}: {e}", path.display());
- None
- }
- }
-}
-
-/// Walks `dir` looking for a `.ttf`/`.otf` file, preferring one whose name
-/// contains "mono". Stops early once a mono-named file is found.
-fn find_ttf_preferring_mono(dir: &std::path::Path, best: &mut Option<std::path::PathBuf>) {
- let Ok(entries) = std::fs::read_dir(dir) else { return };
- for entry in entries.flatten() {
- let path = entry.path();
- if path.is_dir() {
- find_ttf_preferring_mono(&path, best);
- if matches!(best, Some(p) if p.to_string_lossy().to_lowercase().contains("mono")) {
- return;
- }
- continue;
- }
- let is_font = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")).unwrap_or(false);
- if !is_font {
- continue;
- }
- let is_mono = path.to_string_lossy().to_lowercase().contains("mono");
- if is_mono {
- *best = Some(path);
- return;
- }
- if best.is_none() {
- *best = Some(path);
- }
- }
-}
+//! Deliberately has zero `smithay` dependency throughout: every function
+//! here is a pure `(dimensions, ...) -> Vec<u8>` call, unit-testable
+//! without a GL context or display, with a thin adapter in `lib.rs`
+//! uploading each result into a `MemoryRenderBuffer`.
+//!
+//! Split by concern, matching niri's own `render_helpers/` module
+//! boundaries (see `docs/TODO.md`'s "module splits" entry for the research
+//! behind that choice) - this file itself only re-exports each
+//! submodule's own public API plus the two standalone popup renderers that
+//! don't belong to any single one of them:
+//! - [`color`]: byte-order conversion and the two directional colour
+//! blends (`brighten`/`darken`) shared by several submodules.
+//! - [`font`]: locating a system monospace font and blitting its
+//! rasterized glyphs.
+//! - [`corners`]: rounding a bitmap's own top/bottom corners to a
+//! quarter-circle - shared by `titlebar`/`border`.
+//! - [`shadow`]: a window's drop shadow.
+//! - [`border`]: the four strips around a window's `geometry`.
+//! - [`buttons`]: the three titlebar buttons' own dots and glyphs.
+//! - [`titlebar`]: laying out and rasterizing the whole titlebar band,
+//! using `buttons`/`corners`/`font`/`color`.
+
+mod border;
+mod buttons;
+mod color;
+mod corners;
+mod font;
+mod shadow;
+mod titlebar;
+
+pub use border::{border_strips, render_border_bottom, render_border_top};
+pub(crate) use border::{border_bottom_visible_rows, border_top_visible_rows};
+pub(crate) use buttons::HOVER_GLYPH_DURATION;
+pub(crate) use color::rgb_to_bgra;
+pub(crate) use corners::{round_bottom_corners, round_top_corners};
+pub(crate) use font::{blit_glyph, find_system_font, FONT_PIXELS, TEXT_LEFT_PADDING};
+pub use shadow::{shadow_bitmap, shadow_rect};
+pub(crate) use shadow::SHADOW_MAX_ALPHA;
+pub use titlebar::render_titlebar;
-pub(crate) fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] {
- [rgb.2, rgb.1, rgb.0, alpha]
-}
+/// Default corner radius, in pixels, applied to a window at creation
+/// (`Window::corner_radius`/`ThemeConfig::default_corner_radius`) - kept
+/// here only as this file's own test fixture default now that the real
+/// radius is a live, per-window value (`theme.decorations.border.radius`
+/// in config, `srd set corner_radius <n>` live, `srd.window.
+/// set_corner_radius(n)`/a rule's `corner_radius` action per-window).
+/// `render_border_top`/`render_border_bottom`/`render_titlebar` all take
+/// the real radius as a parameter now rather than reading this directly.
+/// `12`, not the original `6`: a `radius / TITLEBAR_HEIGHT` ratio of
+/// `0.36`, matching real macOS's own ~10pt/28pt proportions rather than
+/// this project's original, visibly-tighter `0.2` (docs/TODO.md's
+/// macOS-comparison research) - kept in sync with `ThemeConfig::
+/// default_corner_radius` and the shipped `theme.decorations.border.radius`
+/// default in `crates/srdwm/src/main.rs` so this fixture actually
+/// represents what a fresh install renders. Moves in step with
+/// `srdwm_core::TITLEBAR_HEIGHT` (currently `32`) - same ratio, not a
+/// separate size decision.
+#[cfg(test)]
+pub(crate) const CORNER_RADIUS: u32 = 12;
/// The titlebar right-click window menu (minimize/maximize/always-on-top/
/// close) - the one interaction virtually every desktop WM has always
@@ -220,703 +194,5 @@ pub fn render_snap_flyout(columns: u32, cell_width: u32, cell_height: u32, label
buf
}
-/// The four border strips (top, bottom, left, right) around a window's
-/// full rect, `width` thick, drawn *outside* `geometry` - additive to the
-/// window's on-screen footprint, the same as a native X11 border, rather
-/// than overlapping and clipping into the titlebar or content. This is
-/// purely a rendering concern: `geometry` alone stays authoritative for
-/// hit-testing and placement, nothing reads the strips back.
-///
-/// Without any border at all, a compositor-drawn titlebar and independently
-/// client-rendered content have nothing visually tying them together as
-/// one window - reported live as the titlebar "not seeming part of the
-/// window". `Window.border_color`/`border_width` already existed (and are
-/// drawn by the X11 backend via a native X11 border) but were dead fields
-/// on the Wayland side - `set_border_color`/`set_border_width` were both
-/// no-op stubs.
-pub fn border_strips(geometry: srdwm_core::Rect, width: u32) -> [srdwm_core::Rect; 4] {
- let w = width as i32;
- [
- srdwm_core::Rect::new(geometry.x - w, geometry.y - w, geometry.width + 2 * width, width),
- srdwm_core::Rect::new(geometry.x - w, geometry.y + geometry.height as i32, geometry.width + 2 * width, width),
- srdwm_core::Rect::new(geometry.x - w, geometry.y, width, geometry.height),
- srdwm_core::Rect::new(geometry.x + geometry.width as i32, geometry.y, width, geometry.height),
- ]
-}
-
-/// How far a window's drop shadow extends past its geometry on each side.
-pub const SHADOW_SIZE: u32 = 12;
-
-/// The shadow's darkest alpha, right at the window's own edge - out of
-/// 255. Deliberately subtle (Nord/GNOME-default territory, not a heavy
-/// drop shadow): this compositor has no blur primitive to soften it with
-/// (see `shadow_bitmap`'s own doc comment), so a strong value would read as
-/// a hard dark ring rather than a shadow.
-const SHADOW_MAX_ALPHA: u8 = 90;
-
-/// `geometry` expanded by [`SHADOW_SIZE`] on every side - the full bounding
-/// box [`shadow_bitmap`] rasterises into, and where the caller positions it
-/// (top-left corner at `(geometry.x - SHADOW_SIZE, geometry.y - SHADOW_SIZE)`).
-pub fn shadow_rect(geometry: srdwm_core::Rect) -> srdwm_core::Rect {
- let s = SHADOW_SIZE as i32;
- srdwm_core::Rect::new(geometry.x - s, geometry.y - s, geometry.width + SHADOW_SIZE * 2, geometry.height + SHADOW_SIZE * 2)
-}
-
-/// Renders a window's drop shadow as a BGRA8 bitmap: black at an alpha that
-/// falls off linearly from [`SHADOW_MAX_ALPHA`] right at the window's own
-/// edge to fully transparent [`SHADOW_SIZE`] pixels out. `win_width`/
-/// `win_height` are the window's own footprint (`geometry`, border strips
-/// included if any - whatever the caller already draws as opaque); the
-/// returned bitmap is `shadow_rect`'s size, `SHADOW_SIZE` larger on every
-/// side.
-///
-/// Not a true Gaussian blur - no blur primitive is available without a GPU
-/// shader (the udev backend's `PixmanRenderer` is software-only) or a new
-/// image-processing dependency - so this is a stepless *linear* falloff
-/// using Chebyshev (square-ring) distance from the window's edge rather
-/// than a rounded/radial one, cheap enough to rebuild on every resize (see
-/// the caller for when that is) without a per-pixel sqrt. Reads as "soft
-/// enough" at the sizes a titlebar-height window actually uses, the same
-/// "approximate cutoff over true anti-aliasing" trade-off `round_top_corners`
-/// already makes for corners.
-///
-/// The region directly under the window itself (`dist == 0` below) is left
-/// fully transparent rather than filled - harmless either way since the
-/// window's own border/titlebar/content always draws over it, but skipping
-/// it is one less branch of work for the common case (a window with no
-/// occluders in front of it, so most of the bitmap's interior never
-/// contributes a visible pixel).
-pub fn shadow_bitmap(win_width: u32, win_height: u32) -> Vec<u8> {
- let (win_width, win_height) = (win_width.max(1), win_height.max(1));
- let width = win_width + SHADOW_SIZE * 2;
- let height = win_height + SHADOW_SIZE * 2;
- let mut buf = vec![0u8; (width * height * 4) as usize];
- for y in 0..height {
- let dy = edge_distance(y, SHADOW_SIZE, win_height);
- if dy > SHADOW_SIZE {
- continue;
- }
- for x in 0..width {
- let dx = edge_distance(x, SHADOW_SIZE, win_width);
- let dist = dx.max(dy);
- if dist == 0 || dist > SHADOW_SIZE {
- continue;
- }
- let alpha = (SHADOW_MAX_ALPHA as u32 * (SHADOW_SIZE - dist) / SHADOW_SIZE) as u8;
- if alpha == 0 {
- continue;
- }
- let i = ((y * width + x) * 4) as usize;
- // Premultiplied BGRA, but the colour is black (0, 0, 0) - a
- // premultiplied black pixel is just (0, 0, 0, alpha) at any
- // alpha, so there's no separate multiply step needed here.
- buf[i + 3] = alpha;
- }
- }
- buf
-}
-
-/// How far outside `[margin, margin + extent)` - the window's own span
-/// along one axis, inside the shadow's `margin`-pixel border on each side
-/// - position `pos` sits, in pixels. `0` anywhere inside that span
-/// (including exactly on its edge).
-fn edge_distance(pos: u32, margin: u32, extent: u32) -> u32 {
- if pos < margin {
- margin - pos
- } else if pos >= margin + extent {
- pos - (margin + extent) + 1
- } else {
- 0
- }
-}
-
-/// Renders the top border strip (`border_strips`'s first rect) as a BGRA8
-/// bitmap instead of a plain solid fill, with its own outer top corners cut
-/// the same way `render_titlebar`'s `round_corners` cuts the titlebar's --
-/// see that parameter's doc comment for why a titlebar rounds but a square
-/// border frame around it used to defeat the point. Rounding *this* strip
-/// too, at a radius `width` pixels larger than the titlebar's (so the cut
-/// continues outward from the titlebar's own, rather than starting over),
-/// is what makes a bordered window's corner read as one continuous curve
-/// instead of a rounded titlebar sitting inside a square frame.
-///
-/// [`render_border_bottom`] gives the bottom strip the matching treatment
-/// for its own two corners. The left/right strips don't participate in any
-/// visible corner at all (`border_strips`' geometry has them span only the
-/// height *between* the top and bottom strips) and stay plain solid fills
-/// - see their render call sites.
-pub fn render_border_top(width: u32, thickness: u32, color: (u8, u8, u8), radius: u32) -> Vec<u8> {
- let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
- let bg = rgb_to_bgra(color, 255);
- let mut buf = vec![0u8; width * thickness * 4];
- for px in buf.chunks_exact_mut(4) {
- px.copy_from_slice(&bg);
- }
- round_top_corners(&mut buf, width, thickness, radius + thickness as u32);
- buf
-}
-
-/// [`render_border_top`]'s mirror for the bottom strip - same construction,
-/// its own two corners (bottom-left/bottom-right) cut instead. Reported
-/// live, alongside the top-corner work: a bordered window's bottom two
-/// corners still read as square next to the now-rounded top ones, the same
-/// "inconsistently square" complaint that motivated rounding the top strip
-/// in the first place.
-///
-/// Handled as one all-or-nothing bitmap rather than folded into the
-/// left/right strips' per-fragment occlusion splitting (`visible_border_
-/// fragments`) - the same trade-off `render_border_top`'s own call site
-/// already makes and for the same reason: cropping a rounded bitmap's
-/// source rect per fragment is real extra work for a strip this thin.
-pub fn render_border_bottom(width: u32, thickness: u32, color: (u8, u8, u8), radius: u32) -> Vec<u8> {
- let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
- let bg = rgb_to_bgra(color, 255);
- let mut buf = vec![0u8; width * thickness * 4];
- for px in buf.chunks_exact_mut(4) {
- px.copy_from_slice(&bg);
- }
- round_bottom_corners(&mut buf, width, thickness, radius + thickness as u32);
- buf
-}
-
-/// Renders a `width x height` BGRA8 buffer: filled with `background`, with
-/// `title` drawn left-aligned in `foreground` (best-effort glyph layout --
-/// no text shaping/kerning, adequate for the ASCII-heavy titles window
-/// managers actually display). Returns `None` (caller keeps the previous
-/// solid-color-only look) only if no usable font was found on this system.
-///
-/// `round_corners` should be `false` only for a window whose border strips
-/// are rendered as plain square-cornered fills with no matching rounded
-/// treatment of their own. `render_border_top` gives the border's top strip
-/// the same rounded-corner cut (see its own doc comment for how the two
-/// stay visually continuous), so a normal bordered window should pass
-/// `true` here same as a borderless one now - reported live as most
-/// windows (anything with the default border) looking inconsistently
-/// square next to the few borderless ones that were rounded.
-pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8, u8), foreground: (u8, u8, u8), round_corners: bool, radius: u32) -> Vec<u8> {
- let (width, height) = (width.max(1) as usize, height.max(1) as usize);
- let bg = rgb_to_bgra(background, 255);
- let mut buf = vec![0u8; width * height * 4];
- for px in buf.chunks_exact_mut(4) {
- px.copy_from_slice(&bg);
- }
-
- // Reserve the right-hand button squares before laying out text, so a
- // long title elides under them the same way it would under real window
- // furniture rather than drawing on top of it.
- let button_count = if width >= height * 3 { 3 } else { 0 };
- let text_limit = width.saturating_sub(height * button_count);
-
- if let Some(font) = find_system_font() {
- let baseline = (height as f32 * 0.72).round();
- let mut pen_x = TEXT_LEFT_PADDING;
- for ch in title.chars() {
- if ch.is_control() {
- continue;
- }
- let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
- if metrics.width > 0 && metrics.height > 0 {
- let glyph_x = pen_x + metrics.xmin as f32;
- let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32;
- blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, background, foreground);
- }
- pen_x += metrics.advance_width;
- if pen_x as usize >= text_limit {
- break;
- }
- }
- }
-
- if button_count == 3 {
- draw_minimize_icon(&mut buf, width, height, height * 2, foreground);
- draw_maximize_icon(&mut buf, width, height, height, foreground);
- draw_close_icon(&mut buf, width, height, 0, foreground);
- }
- if round_corners {
- round_top_corners(&mut buf, width, height, radius);
- }
- buf
-}
-
-/// Default corner radius, in pixels, applied to a window at creation
-/// (`Window::corner_radius`/`ThemeConfig::default_corner_radius`) - kept
-/// here only as this file's own test fixture default now that the real
-/// radius is a live, per-window value (`theme.decorations.border.radius`
-/// in config, `srd set corner_radius <n>` live, `srd.window.
-/// set_corner_radius(n)`/a rule's `corner_radius` action per-window).
-/// `render_border_top`/`render_border_bottom`/`render_titlebar` all take
-/// the real radius as a parameter now rather than reading this directly.
#[cfg(test)]
-pub(crate) const CORNER_RADIUS: u32 = 6;
-
-/// Clips the top-left and top-right corners of a titlebar buffer to a
-/// quarter-circle by making the pixels outside it fully transparent, so
-/// whatever's behind (the desktop, on every top-level window) shows through
-/// instead of a hard square corner.
-///
-/// Only the *top* corners: the titlebar's bottom edge meets the window's
-/// content, which this compositor has no way to clip (content is rendered
-/// entirely by the client) - rounding that seam too would need a
-/// compositor-wide clip mask over arbitrary client buffers, a much larger
-/// change than this cosmetic pass. Real desktops mostly round this the same
-/// way: only the outermost corners of a window, not every internal seam.
-///
-/// Hard cutoff rather than an anti-aliased edge, matching this codebase's
-/// existing pixel-art aesthetic elsewhere (the cursor bitmaps) rather than
-/// mixing rendering styles for one corner treatment.
-///
-/// Zeroes all four BGRA bytes for a cut pixel, not just alpha: this buffer
-/// is `Fourcc::Argb8888`, which both Wayland/`wl_shm` and Pixman treat as
-/// premultiplied - a genuinely transparent premultiplied pixel is `(0, 0,
-/// 0, 0)` in every channel, not just alpha, since the stored colour already
-/// carries the alpha multiplied in. Leaving the opaque titlebar-background
-/// RGB behind while zeroing only alpha produced a byte pattern Pixman's own
-/// `OVER` compositing (`result = src + dst * (1 - src_alpha)`) does not
-/// actually treat as "nothing here": with `src_alpha = 0` the formula still
-/// adds the stale, un-premultiplied `src` RGB straight through, so the
-/// "cut" pixel came out opaque and the corner still read as square --
-/// confirmed live, pixel-by-pixel, no visible transparency anywhere in a
-/// window's real top corner despite this function running and a nonzero
-/// radius. `rounded_corners_pixman.rs`'s `apply_corner_mask` - the
-/// equivalent mask for client *content* - already gets this right (scales
-/// all four bytes together); this was the one corner-rounding path in the
-/// codebase that didn't match it.
-fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) {
- let r = (radius as usize).min(width / 2).min(height);
- if r == 0 {
- return;
- }
- // Corner centres: `r` in from each edge, `r` down from the top - the
- // standard quarter-circle-in-a-square construction.
- let is_outside_corner = |x: usize, y: usize, cx: usize, cy: usize| -> bool {
- let (dx, dy) = (x as i64 - cx as i64, y as i64 - cy as i64);
- (dx * dx + dy * dy) as u64 > (r * r) as u64
- };
- for y in 0..r {
- for x in 0..r {
- if is_outside_corner(x, y, r, r) {
- buf[(y * width + x) * 4..(y * width + x) * 4 + 4].fill(0);
- }
- }
- for x in (width - r)..width {
- if is_outside_corner(x, y, width - r - 1, r) {
- buf[(y * width + x) * 4..(y * width + x) * 4 + 4].fill(0);
- }
- }
- }
-}
-
-/// [`round_top_corners`]'s mirror for the bottom two corners - same
-/// construction, corner centres `r` *up* from the bottom instead of down
-/// from the top. Same premultiplied-alpha fix, same reason - see that
-/// function's own doc comment.
-fn round_bottom_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) {
- let r = (radius as usize).min(width / 2).min(height);
- if r == 0 {
- return;
- }
- // `cy` as a signed offset, not a `usize` - `height - r` can be exactly
- // `0` (a strip whose radius clamp landed on its own full height, same
- // as `round_top_corners` allows for `r == height`), which would
- // underflow a plain `usize` subtraction one step further below.
- let is_outside_corner = |x: usize, y: usize, cx: usize, cy: i64| -> bool {
- let (dx, dy) = (x as i64 - cx as i64, y as i64 - cy);
- (dx * dx + dy * dy) as u64 > (r * r) as u64
- };
- let cy = height as i64 - r as i64 - 1;
- for y in (height - r)..height {
- for x in 0..r {
- if is_outside_corner(x, y, r, cy) {
- buf[(y * width + x) * 4..(y * width + x) * 4 + 4].fill(0);
- }
- }
- for x in (width - r)..width {
- if is_outside_corner(x, y, width - r - 1, cy) {
- buf[(y * width + x) * 4..(y * width + x) * 4 + 4].fill(0);
- }
- }
- }
-}
-
-/// Sets one pixel to `color` if it falls inside the buffer - every icon
-/// drawn below goes through this so none of them need their own bounds
-/// checks.
-fn set_px(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: (u8, u8, u8)) {
- if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
- return;
- }
- let idx = (y as usize * width + x as usize) * 4;
- buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(color, 255));
-}
-
-/// The square `right_offset` pixels in from the right edge of the titlebar,
-/// inset by `BUTTON_MARGIN` on each side - the box a button's glyph is
-/// drawn inside.
-fn button_box(width: usize, height: usize, right_offset: usize) -> (i32, i32, i32, i32) {
- let square = height as f32;
- let inset = (square * BUTTON_MARGIN).round() as i32;
- let right = width as i32 - right_offset as i32;
- let left = right - height as i32;
- (left + inset, inset, right - inset, height as i32 - inset)
-}
-
-/// Bresenham line, since none of these icons need anything fancier.
-fn draw_line(buf: &mut [u8], width: usize, height: usize, from: (i32, i32), to: (i32, i32), color: (u8, u8, u8)) {
- let (mut x0, mut y0) = from;
- let (x1, y1) = to;
- let dx = (x1 - x0).abs();
- let dy = -(y1 - y0).abs();
- let sx = if x0 < x1 { 1 } else { -1 };
- let sy = if y0 < y1 { 1 } else { -1 };
- let mut err = dx + dy;
- loop {
- set_px(buf, width, height, x0, y0, color);
- if x0 == x1 && y0 == y1 {
- break;
- }
- let e2 = 2 * err;
- if e2 >= dy {
- err += dy;
- x0 += sx;
- }
- if e2 <= dx {
- err += dx;
- y0 += sy;
- }
- }
-}
-
-fn draw_close_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
- let (x0, y0, x1, y1) = button_box(width, height, right_offset);
- draw_line(buf, width, height, (x0, y0), (x1, y1), color);
- draw_line(buf, width, height, (x0, y1), (x1, y0), color);
-}
-
-fn draw_maximize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
- let (x0, y0, x1, y1) = button_box(width, height, right_offset);
- draw_line(buf, width, height, (x0, y0), (x1, y0), color);
- draw_line(buf, width, height, (x0, y1), (x1, y1), color);
- draw_line(buf, width, height, (x0, y0), (x0, y1), color);
- draw_line(buf, width, height, (x1, y0), (x1, y1), color);
-}
-
-fn draw_minimize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
- let (x0, _, x1, y1) = button_box(width, height, right_offset);
- draw_line(buf, width, height, (x0, y1), (x1, y1), color);
-}
-
-#[allow(clippy::too_many_arguments)]
-pub(crate) fn blit_glyph(
- buf: &mut [u8],
- width: usize,
- height: usize,
- glyph_x: i32,
- glyph_y: i32,
- metrics: &fontdue::Metrics,
- coverage: &[u8],
- background: (u8, u8, u8),
- foreground: (u8, u8, u8),
-) {
- for row in 0..metrics.height {
- let y = glyph_y + row as i32;
- if y < 0 || y as usize >= height {
- continue;
- }
- for col in 0..metrics.width {
- let x = glyph_x + col as i32;
- if x < 0 || x as usize >= width {
- continue;
- }
- let cov = coverage[row * metrics.width + col] as f32 / 255.0;
- if cov <= 0.0 {
- continue;
- }
- let blend = |bg: u8, fg: u8| -> u8 { (bg as f32 * (1.0 - cov) + fg as f32 * cov).round() as u8 };
- let r = blend(background.0, foreground.0);
- let g = blend(background.1, foreground.1);
- let b = blend(background.2, foreground.2);
- let idx = (y as usize * width + x as usize) * 4;
- buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra((r, g, b), 255));
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn border_strips_surround_geometry_without_overlapping_it() {
- let geom = srdwm_core::Rect::new(100, 100, 200, 150);
- let [top, bottom, left, right] = border_strips(geom, 3);
- // Every strip's own rect must stay entirely outside `geom` - these
- // are meant to frame the window, not clip into its own titlebar or
- // content.
- assert_eq!(top, srdwm_core::Rect::new(97, 97, 206, 3));
- assert_eq!(bottom, srdwm_core::Rect::new(97, 250, 206, 3));
- assert_eq!(left, srdwm_core::Rect::new(97, 100, 3, 150));
- assert_eq!(right, srdwm_core::Rect::new(300, 100, 3, 150));
- }
-
- #[test]
- fn shadow_rect_expands_geometry_by_shadow_size_on_every_side() {
- let geom = srdwm_core::Rect::new(100, 100, 200, 150);
- let s = shadow_rect(geom);
- assert_eq!(s, srdwm_core::Rect::new(100 - SHADOW_SIZE as i32, 100 - SHADOW_SIZE as i32, 200 + SHADOW_SIZE * 2, 150 + SHADOW_SIZE * 2));
- }
-
- #[test]
- fn shadow_bitmap_is_the_expected_size_and_transparent_under_the_window() {
- let buf = shadow_bitmap(40, 20);
- let width = 40 + SHADOW_SIZE * 2;
- let height = 20 + SHADOW_SIZE * 2;
- assert_eq!(buf.len(), (width * height * 4) as usize);
- // Dead center is inside the window's own footprint - must stay
- // fully transparent, since the window's own content draws over it.
- let mid = ((height / 2) * width + width / 2) * 4;
- assert_eq!(buf[mid as usize + 3], 0);
- }
-
- #[test]
- fn shadow_bitmap_is_darkest_right_at_the_window_edge_and_fades_outward() {
- let buf = shadow_bitmap(40, 20);
- let width = (40 + SHADOW_SIZE * 2) as usize;
- // Walking straight up from the window's horizontal center, from one
- // pixel above its top edge (row SHADOW_SIZE - 1) out to the shadow's
- // own outer edge (row 0): alpha must start near SHADOW_MAX_ALPHA and
- // strictly decrease to 0.
- let x = width / 2;
- let mut last_alpha = 255u8;
- for row in (0..SHADOW_SIZE as usize).rev() {
- let i = (row * width + x) * 4;
- let alpha = buf[i + 3];
- assert!(alpha <= last_alpha, "alpha rose from {last_alpha} to {alpha} moving outward at row {row}");
- last_alpha = alpha;
- }
- assert_eq!(last_alpha, 0, "outermost row must be fully transparent");
- }
-
- #[test]
- fn fills_background_when_no_text() {
- let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true, CORNER_RADIUS);
- assert_eq!(buf.len(), 40 * 20 * 4);
- // Center, not (0,0): the top-left pixel is inside the rounded
- // corner `round_top_corners` clips away, so it's transparent by
- // design - see `corners_are_clipped_but_the_middle_is_not` below.
- let mid = ((20 / 2) * 40 + 40 / 2) * 4;
- assert_eq!(&buf[mid..mid + 4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255));
- }
-
- #[test]
- fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() {
- // Regression test for a bug where every drawn icon was one full
- // button-width left of where a click on it actually landed: the
- // visible "X" triggered Maximize, the visible square triggered
- // Minimize, and the true Close hit-zone (the rightmost
- // TITLEBAR_HEIGHT-wide band) was blank. `button_box`'s
- // `right_offset` must put each icon in the same square
- // `ResizeEdge::hit_test` assigns to it - checked here by picking
- // the centre pixel of each drawn icon's square and confirming
- // `hit_test` reports the matching button for that same point.
- let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT);
- let bg = (0x2e, 0x34, 0x40);
- let fg = (0xec, 0xef, 0xf4);
- let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS);
- let frame = srdwm_core::Rect::new(0, 0, width, height);
- let (width, height) = (width as usize, height as usize);
-
- let bg_bytes = rgb_to_bgra(bg, 255);
- for (right_offset, expected) in [(0, srdwm_core::TitlebarHit::Close), (height, srdwm_core::TitlebarHit::Maximize), (height * 2, srdwm_core::TitlebarHit::Minimize)] {
- let (x0, y0, x1, y1) = button_box(width, height, right_offset);
- let drawn = (y0..=y1).any(|y| (x0..=x1).any(|x| buf[(y as usize * width + x as usize) * 4..(y as usize * width + x as usize) * 4 + 4] != bg_bytes));
- assert!(drawn, "expected some drawn icon pixel inside the right_offset={right_offset} square");
- let cx = (x0 + x1) / 2;
- let cy = (y0 + y1) / 2;
- assert_eq!(
- srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN),
- Some(expected),
- "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}"
- );
- }
- }
-
- #[test]
- fn drawing_title_changes_some_pixels_when_font_available() {
- if find_system_font().is_none() {
- eprintln!("skipping: no system font found in this sandbox");
- return;
- }
- let bg = (0x2e, 0x34, 0x40);
- let fg = (0xec, 0xef, 0xf4);
- let buf = render_titlebar(200, 30, "Terminal", bg, fg, true, CORNER_RADIUS);
- let bg_bytes = rgb_to_bgra(bg, 255);
- let changed = buf.chunks_exact(4).any(|px| px != bg_bytes);
- assert!(changed, "expected at least one pixel to differ from the background once text is drawn");
- }
-
- #[test]
- fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() {
- let bg = (0x10, 0x20, 0x30);
- let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS);
- let bg_bytes = rgb_to_bgra(bg, 255);
- for (i, px) in buf.chunks_exact(4).enumerate() {
- let (x, y) = (i % width as usize, i / width as usize);
- let in_top_left = x < CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
- let in_top_right = x >= width as usize - CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
- if !in_top_left && !in_top_right {
- assert_eq!(px, bg_bytes, "unexpected non-background pixel at ({x}, {y})");
- }
- }
- }
-
- #[test]
- fn corners_are_clipped_but_the_middle_is_not() {
- let bg = (0x10, 0x20, 0x30);
- let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS);
- let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
- // The very corner pixel is well outside the quarter-circle at any
- // sane radius - fully clipped.
- assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be transparent");
- assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be transparent");
- // Bottom corners are deliberately left square (see the function's
- // doc comment: the titlebar's bottom edge meets client content,
- // which can't be clipped the same way).
- assert_eq!(alpha_at(0, height as usize - 1), 255, "bottom-left must stay square");
- assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255, "bottom-right must stay square");
- // Centre is nowhere near either corner circle - untouched.
- assert_eq!(alpha_at(width as usize / 2, height as usize / 2), 255);
- }
-
- #[test]
- fn clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha() {
- // Regression test: `round_top_corners` used to zero only the alpha
- // byte of a clipped pixel, leaving the opaque background RGB behind
- // it untouched. This buffer is `Fourcc::Argb8888`, which both
- // Wayland/`wl_shm` and Pixman treat as premultiplied - Pixman's own
- // `OVER` compositing (`result = src + dst * (1 - src_alpha)`) does
- // not treat `alpha=0, rgb=<something>` as "contributes nothing": it
- // adds that stale, un-premultiplied `rgb` straight through, so the
- // "clipped" corner still rendered fully opaque and every window's
- // top corners read as square regardless of a nonzero radius --
- // confirmed live, pixel-by-pixel, zero transparency anywhere in a
- // real window's corner. A genuinely transparent premultiplied pixel
- // is `(0, 0, 0, 0)` in every channel, not just alpha.
- let bg = (0x10, 0x20, 0x30);
- let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS);
- let px_at = |x: usize, y: usize| &buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4];
- assert_eq!(px_at(0, 0), [0, 0, 0, 0], "top-left corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
- assert_eq!(px_at(width as usize - 1, 0), [0, 0, 0, 0], "top-right corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
- }
-
- #[test]
- fn round_corners_false_leaves_the_top_corners_square() {
- let bg = (0x10, 0x20, 0x30);
- let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false, CORNER_RADIUS);
- let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
- assert_eq!(alpha_at(0, 0), 255, "top-left corner should stay square when round_corners is false");
- assert_eq!(alpha_at(width as usize - 1, 0), 255, "top-right corner should stay square when round_corners is false");
- }
-
- #[test]
- fn border_top_rounds_its_own_top_corners_to_match_the_titlebar() {
- // Regression coverage for the "not all window borders are rounded"
- // report: a bordered window's titlebar used to render with
- // `round_corners = false` specifically to avoid clashing with this
- // strip's square corners. Now that this strip rounds too, that
- // workaround is gone (`render_titlebar` is always called with
- // `true`) - this just confirms the strip actually does what that
- // change now depends on.
- let color = (0x40, 0x50, 0x60);
- let (width, thickness) = (60, 2);
- let buf = render_border_top(width, thickness, color, CORNER_RADIUS);
- let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
- assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be clipped");
- assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be clipped");
- // A 2px-thick strip is thinner than any sane radius, so the clamp
- // in `round_top_corners` bounds the cut to the strip's own height --
- // the bottom row, at least at the strip's horizontal centre, must
- // stay opaque or there would be no border left to see at all.
- assert_eq!(alpha_at(width as usize / 2, thickness as usize - 1), 255, "centre of the strip must stay opaque");
- }
-
- #[test]
- fn border_bottom_rounds_its_own_bottom_corners() {
- let color = (0x40, 0x50, 0x60);
- let (width, thickness) = (60, 2);
- let buf = render_border_bottom(width, thickness, color, CORNER_RADIUS);
- let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
- assert_eq!(alpha_at(0, thickness as usize - 1), 0, "bottom-left corner pixel should be clipped");
- assert_eq!(alpha_at(width as usize - 1, thickness as usize - 1), 0, "bottom-right corner pixel should be clipped");
- assert_eq!(alpha_at(width as usize / 2, 0), 255, "centre of the strip must stay opaque");
- }
-
- #[test]
- fn context_menu_is_one_row_tall_per_item() {
- let items = [("Minimize", false), ("Maximize", false), ("Always on Top", false), ("Close", false)];
- let buf = render_context_menu(160, 28, &items, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x4c, 0x56, 0x6a), (0x10, 0x10, 0x10));
- assert_eq!(buf.len(), 160 * (28 * 4) * 4);
- }
-
- #[test]
- fn context_menu_highlighted_row_has_a_different_background_than_the_rest() {
- let items = [("Minimize", false), ("Close", true)];
- let bg = (0x2e, 0x34, 0x40);
- let highlight = (0x4c, 0x56, 0x6a);
- let buf = render_context_menu(160, 28, &items, bg, (0xff, 0xff, 0xff), highlight, (0x10, 0x10, 0x10));
- let width = 160usize;
- // Sample a background pixel from each row, away from the text/border.
- let px_at = |x: usize, y: usize| -> [u8; 3] {
- let i = (y * width + x) * 4;
- [buf[i + 2], buf[i + 1], buf[i]] // BGRA -> RGB
- };
- assert_eq!(px_at(100, 5), [bg.0, bg.1, bg.2], "row 0 (not highlighted) should use bg");
- assert_eq!(px_at(100, 33), [highlight.0, highlight.1, highlight.2], "row 1 (highlighted) should use highlight_bg");
- }
-
- #[test]
- fn context_menu_border_is_opaque_at_every_edge() {
- let items = [("Close", false)];
- let buf = render_context_menu(100, 28, &items, (0, 0, 0), (0xff, 0xff, 0xff), (0, 0, 0), (0x99, 0x99, 0x99));
- let alpha_at = |x: usize, y: usize| buf[(y * 100 + x) * 4 + 3];
- assert_eq!(alpha_at(0, 0), 255);
- assert_eq!(alpha_at(99, 0), 255);
- assert_eq!(alpha_at(0, 27), 255);
- assert_eq!(alpha_at(99, 27), 255);
- }
-
- #[test]
- fn snap_flyout_is_sized_for_a_full_grid_of_labels() {
- let labels = ["Left Half", "Right Half", "Top Left", "Top Right", "Bottom Left", "Bottom Right"];
- let buf = render_snap_flyout(3, 90, 60, &labels, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x10, 0x10, 0x10));
- // 3 columns x 2 rows (6 labels / 3 columns, rounded up).
- assert_eq!(buf.len(), (90 * 3) * (60 * 2) * 4);
- }
-
- #[test]
- fn snap_flyout_border_is_opaque_at_every_outer_edge() {
- let labels = ["A", "B", "C", "D", "E", "F"];
- let (cell_w, cell_h) = (90, 60);
- let buf = render_snap_flyout(3, cell_w, cell_h, &labels, (0, 0, 0), (0xff, 0xff, 0xff), (0x99, 0x99, 0x99));
- let (width, height) = (cell_w * 3, cell_h * 2);
- let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
- assert_eq!(alpha_at(0, 0), 255);
- assert_eq!(alpha_at(width as usize - 1, 0), 255);
- assert_eq!(alpha_at(0, height as usize - 1), 255);
- assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255);
- }
-
- #[test]
- fn snap_flyout_has_an_internal_grid_line_between_columns() {
- let labels = ["A", "B", "C", "D", "E", "F"];
- let (cell_w, cell_h) = (90, 60);
- let buf = render_snap_flyout(3, cell_w, cell_h, &labels, (0, 0, 0), (0xff, 0xff, 0xff), (0x99, 0x99, 0x99));
- let width = cell_w * 3;
- // The boundary between column 0 and column 1, away from the outer border.
- let idx = (30 * width as usize + cell_w as usize) * 4;
- assert_eq!(buf[idx + 3], 255, "column boundary must be drawn, not just the outer border");
- }
-}
+mod tests;
diff --git a/crates/wayland/src/decoration/border.rs b/crates/wayland/src/decoration/border.rs
new file mode 100644
index 0000000..472ef3b
--- /dev/null
+++ b/crates/wayland/src/decoration/border.rs
@@ -0,0 +1,184 @@
+//! The four solid-colour strips around a decorated window's own `geometry`
+//! - top/bottom rendered as small rounded bitmaps (their own two outer
+//! corners cut to match `titlebar::render_titlebar`'s), left/right left to
+//! the caller as plain flat fills (`elements::border_side_render_element`).
+
+use super::color::rgb_to_bgra;
+use super::corners::{round_bottom_corners, round_top_corners};
+
+pub fn border_strips(geometry: srdwm_core::Rect, width: u32) -> [srdwm_core::Rect; 4] {
+ let w = width as i32;
+ [
+ srdwm_core::Rect::new(geometry.x - w, geometry.y - w, geometry.width + 2 * width, width),
+ srdwm_core::Rect::new(geometry.x - w, geometry.y + geometry.height as i32, geometry.width + 2 * width, width),
+ srdwm_core::Rect::new(geometry.x - w, geometry.y, width, geometry.height),
+ srdwm_core::Rect::new(geometry.x + geometry.width as i32, geometry.y, width, geometry.height),
+ ]
+}
+
+/// Renders the top border strip (`border_strips`'s first rect) as a BGRA8
+/// bitmap instead of a plain solid fill, with its own outer top corners cut
+/// the same way `titlebar::render_titlebar`'s `round_corners` cuts the
+/// titlebar's - see that parameter's doc comment for why a titlebar rounds
+/// but a square border frame around it used to defeat the point. This
+/// strip's own row 0 sits at the *true* top of the combined titlebar-plus-
+/// border shape (the border is the outermost layer), so it shares the
+/// titlebar's exact same circle - same `radius`, unshifted `center_row` --
+/// rather than a same-centre-different-radius circle of its own; see
+/// `corners::round_top_corners`'s own doc comment for why an earlier
+/// version of this (`radius + thickness` as the radius passed there) drew a
+/// visibly different curve that didn't actually meet the titlebar's at the
+/// seam between them, and `titlebar::render_titlebar`'s call site for the
+/// other half of this pair.
+///
+/// [`render_border_bottom`] gives the bottom strip the matching treatment
+/// for its own two corners.
+///
+/// The buffer is `thickness.max(radius)` rows tall, not always exactly
+/// `thickness` - when `radius > thickness` (true even at this codebase's
+/// own theme defaults, radius 6 over a 4px border), the corner's curve
+/// doesn't finish resolving to flat within just `thickness` rows, and the
+/// left/right strips (`border_side_render_element`, plain flat rectangles
+/// with no curve awareness of their own, starting immediately at this
+/// strip's own bottom edge) have no way to cover the rest of it - the
+/// result was a real, visible wedge of bare background between the
+/// straight border segments and the window's own rounded silhouette,
+/// worse the larger the radius/thickness gap, confirmed live via pixel-
+/// level inspection of a real screenshot (not just reasoned about): the
+/// horizontal top segment only became visible some ~20 columns in from the
+/// corner while the vertical side segment started almost immediately,
+/// exactly the asymmetry a too-short top strip and a curve-blind side
+/// strip produce together. Extending this buffer to the *full* radius
+/// gives the curve enough room to finish, and - since this element draws
+/// on top of the side strips (`render_udev_frame` pushes it first, and
+/// earlier-pushed custom elements composite over later ones) - the extra
+/// rows correctly overpaint the side strip's naive square corner with the
+/// real curve, no changes needed to the side strips or their occlusion-
+/// fragment splitting at all.
+///
+/// Rows past the original `thickness` are real *extra* canvas purely so
+/// the corner columns have room to curve - the middle (non-corner)
+/// columns of those rows sit visually inside where the titlebar/content
+/// begins, not the border ring, so they're forced transparent after
+/// rounding rather than left as solid `color` (which would otherwise paint
+/// a solid border-coloured bar over the titlebar's own left/right edges
+/// for however many rows this extended by).
+pub fn render_border_top(width: u32, thickness: u32, color: (u8, u8, u8), radius: u32) -> Vec<u8> {
+ let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
+ let height = thickness.max(radius as usize).max(1);
+ let bg = rgb_to_bgra(color, 255);
+ let mut buf = vec![0u8; width * height * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+ round_top_corners(&mut buf, width, height, radius, radius as i32);
+ clip_middle_beyond_thickness(&mut buf, width, radius as usize, thickness..height);
+ buf
+}
+
+/// Zeroes the non-corner (middle) columns of every row in `rows` - the
+/// "extra" rows `render_border_top`/`render_border_bottom` add past their
+/// own true `thickness` purely to give a corner's curve room to resolve.
+/// Left untouched, those rows would stay solid `color` outside the two
+/// corner column-ranges (nothing else clips them), painting a border-
+/// coloured bar across whatever the titlebar/content actually owns there.
+/// `radius` here is the corner column width on each side (already clamped
+/// to `width / 2` inside `corners::round_top_corners`/`round_bottom_
+/// corners`, so this re-derives the same clamp rather than trusting the
+/// caller's raw value).
+fn clip_middle_beyond_thickness(buf: &mut [u8], width: usize, radius: usize, rows: std::ops::Range<usize>) {
+ let r = radius.min(width / 2);
+ if r * 2 >= width {
+ return;
+ }
+ for y in rows {
+ let row = y * width * 4;
+ buf[row + r * 4..row + (width - r) * 4].fill(0);
+ }
+}
+
+/// [`render_border_top`]'s mirror for the bottom strip - same construction,
+/// its own two corners (bottom-left/bottom-right) cut instead. Reported
+/// live, alongside the top-corner work: a bordered window's bottom two
+/// corners still read as square next to the now-rounded top ones, the same
+/// "inconsistently square" complaint that motivated rounding the top strip
+/// in the first place.
+///
+/// Handled as one all-or-nothing bitmap rather than folded into the
+/// left/right strips' per-fragment occlusion splitting (`visible_border_
+/// fragments`) - the same trade-off `render_border_top`'s own call site
+/// already makes and for the same reason: cropping a rounded bitmap's
+/// source rect per fragment is real extra work for a strip this thin.
+pub fn render_border_bottom(width: u32, thickness: u32, color: (u8, u8, u8), radius: u32) -> Vec<u8> {
+ let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
+ let height = thickness.max(radius as usize).max(1);
+ let bg = rgb_to_bgra(color, 255);
+ let mut buf = vec![0u8; width * height * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+ // Plain `radius`, not `radius + thickness` (what this line passed
+ // before) - that drew the corner against a *larger*, self-invented
+ // circle than the window's own `corner_radius`, the exact same wrong
+ // shape the top strip's own seam fix already rejected for an
+ // equivalent reason (see `corners::round_top_corners`'s doc comment):
+ // sampling only the near-flat tip of an oversized circle produces a
+ // curve that barely bends at all, not one that matches the window's
+ // real corner. `render_border_top` gets `radius` used unshifted here
+ // for the same reason it does: this buffer's own outermost row is
+ // genuinely the true tip of the shape.
+ round_bottom_corners(&mut buf, width, height, radius);
+ // Extra rows sit above the original `thickness`, not below - the
+ // bottom strip's curve resolves going *up* into content, the mirror of
+ // the top strip's resolving *down* into it. See
+ // `clip_middle_beyond_thickness`'s own doc comment for why this needs
+ // to happen at all.
+ clip_middle_beyond_thickness(&mut buf, width, radius as usize, 0..height - thickness);
+ buf
+}
+
+/// Which rows of `render_border_top`'s own buffer a real call site should
+/// actually draw, and how far below the strip's nominal position to start
+/// (`(start_row, row_count, position_shift_down)`) - pulled out as a
+/// plain, backend-independent function so both `udev/render.rs` and
+/// `winit/render.rs` share one tested answer instead of two hand-written
+/// copies that could quietly drift apart.
+///
+/// `decorated` matters because the buffer's own "extra" rows (past
+/// `border_width`, present whenever `corner_radius > border_width`) are
+/// colour-filled at the two corner columns by design - safe to draw only
+/// because a *decorated* window has a titlebar band directly beneath this
+/// strip to receive them (see `render_border_top`'s own doc comment). An
+/// undecorated (CSD) window has no such band: `frame.y` is the top of the
+/// client's own real content, immediately below the nominal `border_width`
+/// rows, so those same extra rows would paint a border-coloured wedge onto
+/// it instead - confirmed live on a real Firefox window, not assumed.
+/// Cropping to just the nominal rows in that case is the fix; the buffer's
+/// own row 0 already sits at the correct position either way (this strip
+/// grows *downward*, unlike its bottom sibling), so the shift is always 0.
+pub(crate) fn border_top_visible_rows(decorated: bool, border_width: u32, corner_radius: u32) -> (u32, u32, u32) {
+ let border_width = border_width.max(1);
+ if decorated {
+ (0, border_width.max(corner_radius), 0)
+ } else {
+ (0, border_width, 0)
+ }
+}
+
+/// [`border_top_visible_rows`]'s mirror for `render_border_bottom`'s own
+/// buffer, whose extra rows sit *above* the nominal ones (growing upward
+/// into content) rather than below - so the nominal, safe-to-draw-
+/// unconditionally rows are the buffer's *last* `border_width` of them,
+/// starting at `start_row = extra`, and skipping them entirely (undecorated
+/// case) also means skipping the compensating downward-shift-into-content
+/// a decorated window's fuller buffer needs, landing back at this strip's
+/// own unshifted nominal position instead.
+pub(crate) fn border_bottom_visible_rows(decorated: bool, border_width: u32, corner_radius: u32) -> (u32, u32, u32) {
+ let border_width = border_width.max(1);
+ let extra = border_width.max(corner_radius) - border_width;
+ if decorated {
+ (0, border_width.max(corner_radius), extra)
+ } else {
+ (extra, border_width, 0)
+ }
+}
diff --git a/crates/wayland/src/decoration/buttons.rs b/crates/wayland/src/decoration/buttons.rs
new file mode 100644
index 0000000..a965ef6
--- /dev/null
+++ b/crates/wayland/src/decoration/buttons.rs
@@ -0,0 +1,291 @@
+//! The three titlebar buttons' own dots and glyphs - traffic-light fill,
+//! glossy shading, and the four hand-drawn glyph shapes (X / dash / square
+//! / zoom-arrows). `titlebar.rs` owns *laying out* the cluster (which
+//! button goes where, which side, how many); everything here just draws
+//! one button once given its own box.
+
+use super::color::rgb_to_bgra;
+
+/// Titlebar buttons are laid out right-aligned in `srdwm_core::BUTTON_PITCH`-
+/// wide squares, vertically centred inside the taller `height` band --
+/// matching `ResizeEdge::hit_test` in `crates/core/src/window.rs`, whose
+/// `BUTTON` constant is also `BUTTON_PITCH`, not `TITLEBAR_HEIGHT` (see that
+/// constant's own doc comment for why the two were split apart). That
+/// function only computes *where* a click on close/maximize/minimize lands;
+/// nothing painted the buttons themselves, so the whole band was one
+/// undifferentiated bar with no visible way to tell where those three
+/// clickable regions were.
+pub(super) const BUTTON_MARGIN: f32 = 0.32;
+/// Smaller margin used only when `buttons_left` is set - explicitly
+/// requested ("bigger" buttons on the left, matching macOS convention).
+/// The button's own *box* stays the same size as the right-aligned case
+/// (see `ResizeEdge::hit_test`'s matching comment on why growing the box
+/// itself would clip against its own pitch); a smaller margin just lets
+/// the dot fill more of that same box. `0.1667` specifically: a button
+/// diameter is `BUTTON_PITCH * (1 - 2 * margin)`, which at `BUTTON_PITCH
+/// = 24` gives a 16px dot - measured directly against a real, live
+/// Firefox window (column-scanned screenshot, edges at 40% luminance
+/// difference from the titlebar background): dot diameter 16px, centre-
+/// to-centre pitch 24px, at this same system's own scale. A previous
+/// `0.25` (12px dot) undershot this - it came from an estimate against a
+/// downloaded reference screenshot rather than a live, same-scale
+/// measurement.
+pub(super) const BUTTON_MARGIN_LEFT: f32 = 0.1667;
+/// How long the titlebar-button glyph-reveal-on-hover animation takes to
+/// reach full opacity - matches real, extracted libadwaita CSS on this
+/// machine almost exactly (`transition: ... 200ms cubic-bezier(...)` on
+/// `windowcontrols > button > image`, found via `gresource extract` on the
+/// installed `.so`, not guessed), even though this project's own default
+/// mode (`ThemeConfig::button_glyph_always`) animates the glyph itself in
+/// rather than Adwaita's own choice of animating the background circle
+/// with the glyph always shown - the *timing* still carries over as the
+/// one piece of real DE precedent either mode can share.
+pub(crate) const HOVER_GLYPH_DURATION: std::time::Duration = std::time::Duration::from_millis(200);
+
+/// Traffic-light button colours (close/minimize/maximize), matching macOS's
+/// own - and, on this machine, matching what Firefox's own CSD already
+/// renders via the WhiteSur GTK theme (confirmed live via `grim`: an
+/// unfocused Firefox window shows the same flat grey dots
+/// `TRAFFIC_LIGHT_INACTIVE` below produces). srdwm's own SSD titlebar used
+/// to draw a plain outline glyph (X/square/dash) in the ordinary text
+/// colour instead - reported live as looking nothing like the traffic-
+/// light buttons every CSD client on this theme already has, and as
+/// visibly different window furniture between, e.g., Firefox (CSD, real
+/// traffic lights) and a terminal (SSD, srdwm's own outline glyphs) side by
+/// side. These are deliberately plain colour constants, not new theme
+/// fields - the accepted, still-open ask is hover-state glyph/highlight
+/// work on top of this base look (see `docs/TODO.md`), not a configurable
+/// palette for it.
+pub(super) const TRAFFIC_LIGHT_CLOSE: (u8, u8, u8) = (0xff, 0x5f, 0x57);
+pub(super) const TRAFFIC_LIGHT_MINIMIZE: (u8, u8, u8) = (0xff, 0xbd, 0x2e);
+pub(super) const TRAFFIC_LIGHT_MAXIMIZE: (u8, u8, u8) = (0x28, 0xc8, 0x40);
+/// Unfocused state for all three buttons - real macOS (and this WhiteSur
+/// theme) dims every traffic light to the same flat grey when its window
+/// isn't active, rather than keeping the colours at reduced opacity.
+pub(super) const TRAFFIC_LIGHT_INACTIVE: (u8, u8, u8) = (0x6e, 0x6e, 0x6e);
+
+/// The `srdwm_core::BUTTON_PITCH`-square box a button's dot is drawn inside,
+/// `offset` pixels in from whichever edge `from_left` selects and centred
+/// vertically inside the taller `height` titlebar band - the box's own
+/// size is the same regardless of side (see `ResizeEdge::hit_test`'s
+/// matching comment on why a *bigger box* on the left would risk the dot
+/// clipping against its own pitch; only the margin, and so the dot within
+/// the same box, actually grows there - `titlebar::render_titlebar`'s own
+/// call site picks `BUTTON_MARGIN`/`BUTTON_MARGIN_LEFT` accordingly).
+pub(super) fn button_box(width: usize, height: usize, offset: usize, from_left: bool, margin: f32) -> (i32, i32, i32, i32) {
+ let square = srdwm_core::BUTTON_PITCH as i32;
+ let inset = (square as f32 * margin).round() as i32;
+ let top = ((height as i32 - square) / 2).max(0);
+ let (left, right) = if from_left { (offset as i32, offset as i32 + square) } else { (width as i32 - offset as i32 - square, width as i32 - offset as i32) };
+ (left + inset, top + inset, right - inset, top + square - inset)
+}
+
+/// Fills a traffic-light dot centred in its button square, anti-aliased the
+/// same `smoothstep` way `corners::blend_corner_pixel` rounds a window's
+/// own corners - a hard-edged circle at this size (typically well under
+/// `TITLEBAR_HEIGHT`, i.e. a ~20px-diameter dot) read as visibly jagged,
+/// the same class of problem the corner-seam fix already solved for a
+/// bigger radius. Unlike that function (which reduces an existing pixel's
+/// alpha to clip it away), this blends *toward* `color` over whatever's
+/// already in `buf` - the titlebar background, always already opaque here
+/// - so the result stays fully opaque at every edge pixel rather than
+/// letting the background show through a soft ring.
+pub(super) fn fill_button_dot(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, color: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = button_box(width, height, offset, from_left, margin);
+ let cx = (x0 + x1) as f32 / 2.0;
+ let cy = (y0 + y1) as f32 / 2.0;
+ let radius = ((x1 - x0).min(y1 - y0) as f32 / 2.0).max(0.0);
+ let span = radius.ceil() as i32 + 2;
+ for y in (cy.round() as i32 - span)..=(cy.round() as i32 + span) {
+ if y < 0 || y as usize >= height {
+ continue;
+ }
+ for x in (cx.round() as i32 - span)..=(cx.round() as i32 + span) {
+ if x < 0 || x as usize >= width {
+ continue;
+ }
+ let (dx, dy) = (x as f32 - cx, y as f32 - cy);
+ let dist = (dx * dx + dy * dy).sqrt();
+ let t = ((dist - (radius - 1.0)) / 2.0).clamp(0.0, 1.0);
+ let coverage = 1.0 - (t * t * (3.0 - 2.0 * t));
+ if coverage <= 0.0 {
+ continue;
+ }
+ // Shaded per-pixel, not once for the whole dot - see
+ // `glossy_shade`'s own doc comment for why a flat fill read as
+ // noticeably flatter than real macOS's own traffic lights.
+ let target = rgb_to_bgra(glossy_shade(color, dx, dy, radius.max(1.0)), 255);
+ let idx = (y as usize * width + x as usize) * 4;
+ if coverage >= 1.0 {
+ buf[idx..idx + 4].copy_from_slice(&target);
+ continue;
+ }
+ for c in 0..3 {
+ let existing = buf[idx + c] as f32;
+ buf[idx + c] = (existing + (target[c] as f32 - existing) * coverage).round() as u8;
+ }
+ buf[idx + 3] = 255;
+ }
+ }
+}
+
+/// Shades a flat traffic-light colour into the soft glossy-sphere look real
+/// macOS buttons have, referenced directly against a real screenshot
+/// (Finder's own traffic lights, `~/Downloads`) rather than guessed at: a
+/// gentle highlight toward the upper-left, where its own light source
+/// sits, fading through the flat colour and into a touch of shadow toward
+/// the lower-right rim. Deliberately restrained on both ends - the lit
+/// side never blows out to white and the shadowed side never drops to a
+/// hard black ring - so every dot still reads as its own colour at a
+/// glance, just with real dimensionality instead of a flat fill. `(dx,
+/// dy)` is the pixel's own offset from the dot's centre, in the same units
+/// as `radius`, so this has no dependency on the caller's coordinate
+/// system beyond that.
+fn glossy_shade(color: (u8, u8, u8), dx: f32, dy: f32, radius: f32) -> (u8, u8, u8) {
+ let (nx, ny) = (dx / radius, dy / radius);
+ // Light source up and to the left - the same convention every real
+ // desktop's own icon/button shading already uses.
+ const LIGHT: (f32, f32) = (-0.55, -0.7);
+ const LIGHT_LEN: f32 = 0.888_819_44; // sqrt(0.55^2 + 0.7^2), precomputed
+ let facing = (nx * LIGHT.0 + ny * LIGHT.1) / LIGHT_LEN;
+ // A glossy sphere isn't uniformly lit even on its bright side - it
+ // dims gradually toward every edge, not just the shadowed one.
+ let rim = (nx * nx + ny * ny).min(1.0);
+ let highlight = facing.max(0.0) * (1.0 - rim * 0.4);
+ let shadow = (-facing).max(0.0) * 0.5 + rim * 0.15;
+ let mix_toward = |c: u8, target: f32, amount: f32| (c as f32 + (target - c as f32) * amount).clamp(0.0, 255.0) as u8;
+ let lit = (mix_toward(color.0, 255.0, highlight * 0.45), mix_toward(color.1, 255.0, highlight * 0.45), mix_toward(color.2, 255.0, highlight * 0.45));
+ (mix_toward(lit.0, 0.0, shadow * 0.35), mix_toward(lit.1, 0.0, shadow * 0.35), mix_toward(lit.2, 0.0, shadow * 0.35))
+}
+
+/// A semi-opaque colour blended over whatever's already at `(x, y)`, scaled
+/// by both `alpha` (the glyph-reveal animation's own current progress --
+/// see `tick_hover_glyph_animation`, or a flat 255 in `glyph_always` mode)
+/// and `coverage` (this pixel's own distance-based antialiasing weight from
+/// `blend_glyph_line`, 0..=1). `alpha == 0` is a plain no-op, so a not-yet-
+/// hovered button pays nothing for a glyph nobody can see yet, not even a
+/// fully-transparent draw call. `shade` is the colour blended toward --
+/// near-black for a glyph drawn on a traffic light's own bright fill, or
+/// the titlebar's real foreground colour for one drawn straight on the
+/// titlebar background instead (see `titlebar::render_titlebar`'s own
+/// `glyph_shade` local for which, and why).
+fn blend_glyph_px(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, alpha: u8, coverage: f32, shade: (u8, u8, u8)) {
+ if x < 0 || y < 0 || x as usize >= width || y as usize >= height || alpha == 0 || coverage <= 0.0 {
+ return;
+ }
+ let idx = (y as usize * width + x as usize) * 4;
+ let a = (alpha as f32 / 255.0) * coverage.min(1.0);
+ for (c, target) in [shade.0, shade.1, shade.2].into_iter().enumerate() {
+ let existing = buf[idx + c] as f32;
+ buf[idx + c] = (existing + (target as f32 - existing) * a).round().clamp(0.0, 255.0) as u8;
+ }
+}
+
+/// Half the glyph stroke's own width, in pixels, before the antialiased
+/// feather outside it - `0.55` reads as a crisp, thin stroke at this dot's
+/// own ~16px scale, matching how thin a real toolkit-rendered traffic-light
+/// glyph actually is. Reported live (a real, live screenshot, not the
+/// downloaded macOS reference this session started from) as visibly too
+/// bold at the previous `1.0` - a real glyph is a hairline, not a stroke
+/// that reads as almost as thick as the dot's own edge AA.
+const GLYPH_HALF_WIDTH: f32 = 0.55;
+
+/// A line segment with a soft, antialiased stroke - a raw Bresenham 1px
+/// line (the original implementation) has hard-stepped, jagged edges on
+/// any diagonal, which stood out badly against every other shape in this
+/// file (`fill_button_dot`, `corners::blend_corner_pixel`) already being
+/// smoothstep-antialiased. Distance-to-segment per candidate pixel, not a
+/// stepped walk, so the diagonal close-glyph "X" gets the same smooth edge
+/// its own button dot does. `shade` - see `blend_glyph_px`'s own doc
+/// comment - passes straight through unchanged.
+fn blend_glyph_line(buf: &mut [u8], width: usize, height: usize, from: (i32, i32), to: (i32, i32), alpha: u8, shade: (u8, u8, u8)) {
+ if alpha == 0 {
+ return;
+ }
+ let (x0, y0) = (from.0 as f32, from.1 as f32);
+ let (x1, y1) = (to.0 as f32, to.1 as f32);
+ let (dx, dy) = (x1 - x0, y1 - y0);
+ let len2 = (dx * dx + dy * dy).max(0.0001);
+ const FEATHER: f32 = 0.7;
+ let reach = (GLYPH_HALF_WIDTH + FEATHER).ceil() as i32;
+ let (xmin, xmax) = (from.0.min(to.0) - reach, from.0.max(to.0) + reach);
+ let (ymin, ymax) = (from.1.min(to.1) - reach, from.1.max(to.1) + reach);
+ for y in ymin..=ymax {
+ if y < 0 || y as usize >= height {
+ continue;
+ }
+ for x in xmin..=xmax {
+ if x < 0 || x as usize >= width {
+ continue;
+ }
+ let t = (((x as f32 - x0) * dx + (y as f32 - y0) * dy) / len2).clamp(0.0, 1.0);
+ let (px, py) = (x0 + t * dx, y0 + t * dy);
+ let dist = ((x as f32 - px).powi(2) + (y as f32 - py).powi(2)).sqrt();
+ let ft = ((dist - GLYPH_HALF_WIDTH) / FEATHER).clamp(0.0, 1.0);
+ let coverage = 1.0 - (ft * ft * (3.0 - 2.0 * ft));
+ blend_glyph_px(buf, width, height, x, y, alpha, coverage, shade);
+ }
+ }
+}
+
+/// The `[0.46]` shrink is deliberate, not arbitrary - the glyph has to sit
+/// visibly *inside* the dot's own circular edge (see `fill_button_dot`),
+/// not touch or cross it, matching real macOS traffic-light glyphs, which
+/// are always noticeably smaller than the button itself. `0.46`, not an
+/// earlier `0.62`: reported live (a rendered dump compared directly
+/// against a real reference screenshot) as too big - a real macOS hover
+/// glyph reads as a small, delicate mark centred in the dot, not a shape
+/// that nearly fills it.
+fn glyph_box(width: usize, height: usize, offset: usize, from_left: bool, margin: f32) -> (i32, i32, i32, i32) {
+ let (x0, y0, x1, y1) = button_box(width, height, offset, from_left, margin);
+ let (cx, cy) = ((x0 + x1) as f32 / 2.0, (y0 + y1) as f32 / 2.0);
+ let half = (x1 - x0).min(y1 - y0) as f32 / 2.0 * 0.46;
+ ((cx - half).round() as i32, (cy - half).round() as i32, (cx + half).round() as i32, (cy + half).round() as i32)
+}
+
+pub(super) fn draw_close_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ blend_glyph_line(buf, width, height, (x0, y0), (x1, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y1), (x1, y0), alpha, shade);
+}
+
+/// The plain square maximize icon - this project's own original look
+/// (`traffic_lights = false`, a real Windows/GNOME titlebar's own
+/// convention), and still what a traffic-light-style maximize falls back
+/// to if it doesn't get `draw_zoom_glyph` instead. See `titlebar::
+/// render_titlebar`'s own call site for which mode picks which.
+pub(super) fn draw_maximize_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ blend_glyph_line(buf, width, height, (x0, y0), (x1, y0), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y1), (x1, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y0), (x0, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x1, y0), (x1, y1), alpha, shade);
+}
+
+pub(super) fn draw_minimize_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ let mid = (y0 + y1) / 2;
+ blend_glyph_line(buf, width, height, (x0, mid), (x1, mid), alpha, shade);
+}
+
+/// Real macOS's "zoom" maximize glyph - a double-headed diagonal arrow,
+/// not a square - for `traffic_lights = true` only (see `titlebar::
+/// render_titlebar`'s own call site). One diagonal shaft from the glyph
+/// box's bottom-left to its top-right corner, plus a small two-stroke
+/// arrowhead at each end pointing further outward (away from the glyph's
+/// own centre) - the same primitive (`blend_glyph_line`) every other
+/// glyph here already uses, so this reads as the same family of icon
+/// rather than a different rendering technique bolted on just for this one
+/// shape.
+pub(super) fn draw_zoom_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ blend_glyph_line(buf, width, height, (x0, y1), (x1, y0), alpha, shade);
+ let head = (((x1 - x0).max(1) as f32) * 0.4).round() as i32;
+ // Top-right arrowhead, pointing further up-right (away from centre).
+ blend_glyph_line(buf, width, height, (x1, y0), (x1 - head, y0), alpha, shade);
+ blend_glyph_line(buf, width, height, (x1, y0), (x1, y0 + head), alpha, shade);
+ // Bottom-left arrowhead, pointing further down-left (away from centre).
+ blend_glyph_line(buf, width, height, (x0, y1), (x0 + head, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y1), (x0, y1 - head), alpha, shade);
+}
+
diff --git a/crates/wayland/src/decoration/color.rs b/crates/wayland/src/decoration/color.rs
new file mode 100644
index 0000000..7ea9d80
--- /dev/null
+++ b/crates/wayland/src/decoration/color.rs
@@ -0,0 +1,34 @@
+//! Small, dependency-free colour helpers shared across every other
+//! `decoration` submodule - converting to the renderer's own byte order,
+//! and the two directional blends (`brighten`/`darken`) button/glyph
+//! shading needs. Nothing here reads a pixel buffer or knows what a
+//! titlebar/border/shadow is; see `buttons.rs` for the module that actually
+//! applies these.
+
+pub(crate) fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] {
+ [rgb.2, rgb.1, rgb.0, alpha]
+}
+
+/// Lightens a button colour for the hover state - see `render_titlebar`'s
+/// `hovered` parameter. Blends toward white rather than just scaling each
+/// channel up, so a fully-saturated channel (e.g. green's `0x00` blue) still
+/// visibly brightens instead of clamping at its own max with nothing left
+/// to move.
+pub(crate) fn brighten(color: (u8, u8, u8)) -> (u8, u8, u8) {
+ const AMOUNT: f32 = 0.35;
+ let mix = |c: u8| (c as f32 + (255.0 - c as f32) * AMOUNT).round() as u8;
+ (mix(color.0), mix(color.1), mix(color.2))
+}
+
+/// Darkens a colour toward black by a fixed fraction - used for a traffic-
+/// light glyph's own shade (see `render_titlebar`'s call site): real macOS
+/// draws each button's glyph as a *darker shade of that same button's own
+/// hue* (a dark red mark on the red button, dark amber on the yellow one),
+/// not one universal near-black tint reused across all three - reported
+/// live as looking too dark/heavy and not colour-matched once compared
+/// directly against a real hover-glyph screenshot.
+pub(crate) fn darken(color: (u8, u8, u8)) -> (u8, u8, u8) {
+ const AMOUNT: f32 = 0.45;
+ let mix = |c: u8| (c as f32 * (1.0 - AMOUNT)).round() as u8;
+ (mix(color.0), mix(color.1), mix(color.2))
+}
diff --git a/crates/wayland/src/decoration/corners.rs b/crates/wayland/src/decoration/corners.rs
new file mode 100644
index 0000000..db32209
--- /dev/null
+++ b/crates/wayland/src/decoration/corners.rs
@@ -0,0 +1,165 @@
+//! Rounding a rasterized titlebar/border bitmap's own top or bottom
+//! corners to a quarter-circle, by fading the pixels outside it to fully
+//! transparent - the CPU-bitmap equivalent of `rounded_corners.rs`'s GLES
+//! fragment shader, for the software-only udev/Pixman render path. Shared
+//! by `titlebar.rs` and `border.rs`, which both cut corners out of their
+//! own otherwise-independent buffers and need the two curves to agree
+//! exactly where they meet.
+
+/// Clips the top-left and top-right corners of a titlebar buffer to a
+/// quarter-circle by making the pixels outside it fully transparent, so
+/// whatever's behind (the desktop, on every top-level window) shows through
+/// instead of a hard square corner.
+///
+/// Only the *top* corners: the titlebar's bottom edge meets the window's
+/// content, which this compositor has no way to clip (content is rendered
+/// entirely by the client) - rounding that seam too would need a
+/// compositor-wide clip mask over arbitrary client buffers, a much larger
+/// change than this cosmetic pass. Real desktops mostly round this the same
+/// way: only the outermost corners of a window, not every internal seam.
+///
+/// Hard cutoff rather than an anti-aliased edge, matching this codebase's
+/// existing pixel-art aesthetic elsewhere (the cursor bitmaps) rather than
+/// mixing rendering styles for one corner treatment.
+///
+/// Zeroes all four BGRA bytes for a cut pixel, not just alpha: this buffer
+/// is `Fourcc::Argb8888`, which both Wayland/`wl_shm` and Pixman treat as
+/// premultiplied - a genuinely transparent premultiplied pixel is `(0, 0,
+/// 0, 0)` in every channel, not just alpha, since the stored colour already
+/// carries the alpha multiplied in. Leaving the opaque titlebar-background
+/// RGB behind while zeroing only alpha produced a byte pattern Pixman's own
+/// `OVER` compositing (`result = src + dst * (1 - src_alpha)`) does not
+/// actually treat as "nothing here": with `src_alpha = 0` the formula still
+/// adds the stale, un-premultiplied `src` RGB straight through, so the
+/// "cut" pixel came out opaque and the corner still read as square --
+/// confirmed live, pixel-by-pixel, no visible transparency anywhere in a
+/// window's real top corner despite this function running and a nonzero
+/// radius. `rounded_corners_pixman.rs`'s `apply_corner_mask` - the
+/// equivalent mask for client *content* - already gets this right (scales
+/// all four bytes together); this was the one corner-rounding path in the
+/// codebase that didn't match it.
+/// `center_row` is which row of *this* buffer's own local coordinates the
+/// corner circle's centre sits on - not always `radius` itself. A plain
+/// titlebar with nothing above it passes `radius as i32` (the ordinary
+/// case: the circle's top tip is this buffer's own row 0, same as this
+/// function always assumed before `center_row` existed). A border-top
+/// strip sitting `thickness` rows *above* the titlebar it visually
+/// continues into needs the *same* radius and the *same* circle - not a
+/// same-centre-different-radius circle of its own, which is what passing
+/// `radius + thickness` here used to do (see the doc comment on
+/// `render_border_top`'s call site for why that was tried first). Two
+/// concentric circles of different radii do not meet smoothly at any
+/// boundary between them: at the exact seam, one buffer's mask is
+/// computed against one radius and the other buffer's mask is computed
+/// one pixel later against a different radius, producing a visible jump
+/// rather than a continuous curve - confirmed live, screenshotted at
+/// actual render resolution, not just reasoned about: the titlebar-to-
+/// border seam showed a hard stepped notch, not a curve. Since a border
+/// strip's own row 0 already sits at the *true* top of the combined
+/// shape, it passes `radius as i32` too (unshifted) - it's the titlebar,
+/// starting `thickness` rows *into* the circle instead of at its top,
+/// that needs to shift, by passing `radius as i32 - border_width as
+/// i32` (see `render_titlebar`'s call site).
+pub(crate) fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32, center_row: i32) {
+ let r = (radius as usize).min(width / 2);
+ if r == 0 {
+ return;
+ }
+ let rf = r as f32;
+ let cy = center_row as f32;
+ // Only rows that could plausibly need blending at all: below
+ // `center_row` (this buffer's slice of the circle, whatever portion
+ // of it falls within `[0, height)`) is where the actual curve lives;
+ // rows above `center_row - r` or at/below `center_row` are either
+ // already past the transparent tip or already fully inside the
+ // shape, and calling `blend_corner_pixel` there would either be a
+ // wasted no-op (large `dist`, `mask >= 1`) or - critically, for a
+ // *tall* buffer whose straight edge extends far past the corner --
+ // wrongly compute a huge `dist` from being far below the centre and
+ // clip an ordinary straight-edge pixel to transparent. The original
+ // unshifted version of this function avoided that the same way, by
+ // simply never iterating past row `r`; this is that same bound,
+ // generalised to an arbitrary `center_row`.
+ let y_lo = (center_row - r as i32).max(0) as usize;
+ let y_hi = (center_row.max(0) as usize).min(height);
+ for y in y_lo..y_hi {
+ for x in 0..r {
+ blend_corner_pixel(buf, width, x, y, rf, cy, rf);
+ }
+ for x in (width - r)..width {
+ blend_corner_pixel(buf, width, x, y, (width - r - 1) as f32, cy, rf);
+ }
+ }
+}
+
+/// Multiplies the pixel at `(x, y)` by a smoothed 0..1 mask based on its
+/// distance from `(cx, cy)` versus `radius` - `1` (unchanged) well inside
+/// the circle, `0` (fully transparent) well outside it, blended over a ~2px
+/// band at the boundary. Same anti-aliasing technique `rounded_corners.rs`'s
+/// GLES fragment shader already uses for content rounding
+/// (`smoothstep(radius - 1.0, radius + 1.0, dist)`), applied here to a CPU
+/// bitmap pixel by pixel instead of a per-fragment shader.
+///
+/// The previous version of both callers did a hard binary cut instead --
+/// fully opaque or fully transparent, nothing between - which read as a
+/// jagged single-pixel "break" in the border line rather than a curve,
+/// especially in a border strip only a couple of rows tall (the common
+/// case: `border_width` is usually 2-3px) where there's no room for the
+/// eye to average a staircase into something that looks round. Reported
+/// live as "line breaks" right where a window's border met its curved
+/// corner.
+///
+/// `buf` is premultiplied BGRA (`color::rgb_to_bgra`'s own convention), so
+/// scaling all four bytes by the same factor is the correct way to reduce a
+/// pixel's effective alpha - same reasoning
+/// `clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha`
+/// already established for the hard-cut case this replaces.
+fn blend_corner_pixel(buf: &mut [u8], width: usize, x: usize, y: usize, cx: f32, cy: f32, radius: f32) {
+ let (dx, dy) = (x as f32 - cx, y as f32 - cy);
+ let dist = (dx * dx + dy * dy).sqrt();
+ let t = ((dist - (radius - 1.0)) / 2.0).clamp(0.0, 1.0);
+ let mask = 1.0 - (t * t * (3.0 - 2.0 * t));
+ if mask >= 1.0 {
+ return;
+ }
+ let idx = (y * width + x) * 4;
+ if mask <= 0.0 {
+ buf[idx..idx + 4].fill(0);
+ return;
+ }
+ for c in &mut buf[idx..idx + 4] {
+ *c = (*c as f32 * mask).round() as u8;
+ }
+}
+
+/// [`round_top_corners`]'s mirror for the bottom two corners - same
+/// construction, corner centres `r` *up* from the bottom instead of down
+/// from the top. Same anti-aliasing, same reason - see
+/// [`blend_corner_pixel`]'s own doc comment.
+pub(crate) fn round_bottom_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) {
+ let r = (radius as usize).min(width / 2);
+ if r == 0 {
+ return;
+ }
+ // See `round_top_corners`' matching comment: `r` (the real corner
+ // radius) must stay unclamped by `height`, or a strip thinner than the
+ // radius cuts its own separate, too-tight arc instead of continuing the
+ // titlebar's. `rows` is just how many of that circle's rows this
+ // buffer actually has room for.
+ let rows = r.min(height);
+ let rf = r as f32;
+ // As a float, not the signed-`i64`-offset trick the hard-cut version
+ // needed to avoid a `usize` underflow - `blend_corner_pixel` already
+ // takes float centres, so `height - r - 1` going negative when `r >
+ // height` (the strip-thinner-than-radius case above) is just a
+ // negative `f32`, no special-casing required.
+ let cy = height as f32 - rf - 1.0;
+ for y in (height - rows)..height {
+ for x in 0..r {
+ blend_corner_pixel(buf, width, x, y, rf, cy, rf);
+ }
+ for x in (width - r)..width {
+ blend_corner_pixel(buf, width, x, y, (width - r - 1) as f32, cy, rf);
+ }
+ }
+}
diff --git a/crates/wayland/src/decoration/font.rs b/crates/wayland/src/decoration/font.rs
new file mode 100644
index 0000000..27df347
--- /dev/null
+++ b/crates/wayland/src/decoration/font.rs
@@ -0,0 +1,146 @@
+//! Locating a system monospace font and blitting its rasterized glyphs into
+//! a titlebar's own pixel buffer - everything `render_titlebar`'s title-
+//! text pass needs, kept separate from the titlebar layout/button logic
+//! that actually calls it.
+
+use super::color::rgb_to_bgra;
+use fontdue::{Font, FontSettings};
+use std::sync::OnceLock;
+
+pub(crate) const FONT_PIXELS: f32 = 13.0;
+pub(crate) const TEXT_LEFT_PADDING: f32 = 8.0;
+
+/// Common monospace font file locations on Linux desktops. Not a full
+/// fontconfig query (no new system dependency for something this small) --
+/// if none of these resolve, titlebars fall back to solid-color-only, same
+/// as before text rendering existed.
+pub(crate) fn find_system_font() -> Option<Font> {
+ static FONT: OnceLock<Option<Font>> = OnceLock::new();
+ FONT.get_or_init(load_any_monospace_font).clone()
+}
+
+fn load_any_monospace_font() -> Option<Font> {
+ let roots = ["/usr/share/fonts", "/usr/local/share/fonts"];
+ let mut home_roots = Vec::new();
+ if let Ok(home) = std::env::var("HOME") {
+ home_roots.push(format!("{home}/.local/share/fonts"));
+ home_roots.push(format!("{home}/.fonts"));
+ }
+ let all_roots = roots.iter().map(|s| s.to_string()).chain(home_roots);
+
+ let mut best: Option<(std::path::PathBuf, u8)> = None;
+ for root in all_roots {
+ find_best_font(std::path::Path::new(&root), &mut best);
+ if matches!(best, Some((_, 0))) {
+ break;
+ }
+ }
+ let (path, _) = best?;
+ let bytes = std::fs::read(&path).ok()?;
+ match Font::from_bytes(bytes, FontSettings::default()) {
+ Ok(f) => {
+ log::info!("wayland titlebar font: {}", path.display());
+ Some(f)
+ }
+ Err(e) => {
+ log::warn!("failed to parse font {}: {e}", path.display());
+ None
+ }
+ }
+}
+
+/// Ranks a font file by how suitable it is for titlebar text: `0` (best) is
+/// a mono-named file with no weight/style marker at all (a plain
+/// "Regular"), `1` is mono but italic/oblique/some other non-regular
+/// weight, `2` is not mono-named at all. Lower is better.
+///
+/// The style-marker list matters as much as the mono check: without it, a
+/// directory listing that happens to turn up `...Mono...-Italic.ttf`
+/// before any regular-weight mono file sorts no worse than one, and gets
+/// picked and stuck with for the process's whole lifetime (`find_system_
+/// font`'s own `OnceLock`). Reported live: this exact case, `/usr/share/
+/// fonts/TTF/JetBrainsMonoNerdFontPropo-Italic.ttf` picked over every
+/// regular-weight JetBrains Mono variant also installed on the same
+/// system, purely because "mono" matched and nothing excluded italic --
+/// every titlebar rendered in italic instead of upright text.
+fn font_rank(path: &std::path::Path) -> u8 {
+ let name = path.to_string_lossy().to_lowercase();
+ let is_mono = name.contains("mono");
+ let is_styled =
+ ["italic", "oblique", "bold", "light", "thin", "black", "medium", "semibold", "extrabold", "condensed"].iter().any(|s| name.contains(s));
+ match (is_mono, is_styled) {
+ (true, false) => 0,
+ (true, true) => 1,
+ (false, _) => 2,
+ }
+}
+
+/// Walks `dir` for the lowest-`font_rank` `.ttf`/`.otf` file, checking
+/// every file rather than stopping at the first mono match - unlike rank
+/// alone, "first found" says nothing about *style*, and the filesystem's
+/// own directory-listing order is not something to trust for that. Only
+/// stops early once a genuine rank-`0` (mono, unstyled) match is found,
+/// since nothing could ever beat that.
+fn find_best_font(dir: &std::path::Path, best: &mut Option<(std::path::PathBuf, u8)>) {
+ if matches!(best, Some((_, 0))) {
+ return;
+ }
+ let Ok(entries) = std::fs::read_dir(dir) else { return };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ find_best_font(&path, best);
+ if matches!(best, Some((_, 0))) {
+ return;
+ }
+ continue;
+ }
+ let is_font = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")).unwrap_or(false);
+ if !is_font {
+ continue;
+ }
+ let rank = font_rank(&path);
+ if best.as_ref().is_none_or(|(_, r)| rank < *r) {
+ *best = Some((path, rank));
+ if rank == 0 {
+ return;
+ }
+ }
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn blit_glyph(
+ buf: &mut [u8],
+ width: usize,
+ height: usize,
+ glyph_x: i32,
+ glyph_y: i32,
+ metrics: &fontdue::Metrics,
+ coverage: &[u8],
+ background: (u8, u8, u8),
+ foreground: (u8, u8, u8),
+) {
+ for row in 0..metrics.height {
+ let y = glyph_y + row as i32;
+ if y < 0 || y as usize >= height {
+ continue;
+ }
+ for col in 0..metrics.width {
+ let x = glyph_x + col as i32;
+ if x < 0 || x as usize >= width {
+ continue;
+ }
+ let cov = coverage[row * metrics.width + col] as f32 / 255.0;
+ if cov <= 0.0 {
+ continue;
+ }
+ let blend = |bg: u8, fg: u8| -> u8 { (bg as f32 * (1.0 - cov) + fg as f32 * cov).round() as u8 };
+ let r = blend(background.0, foreground.0);
+ let g = blend(background.1, foreground.1);
+ let b = blend(background.2, foreground.2);
+ let idx = (y as usize * width + x as usize) * 4;
+ buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra((r, g, b), 255));
+ }
+ }
+}
diff --git a/crates/wayland/src/decoration/shadow.rs b/crates/wayland/src/decoration/shadow.rs
new file mode 100644
index 0000000..df0a0dd
--- /dev/null
+++ b/crates/wayland/src/decoration/shadow.rs
@@ -0,0 +1,186 @@
+//! A window's drop shadow, rasterized as its own BGRA8 bitmap - extent,
+//! sizing, and the corner-aware falloff distance function it needs to read
+//! as rounded next to a window with a real rounded corner, rather than a
+//! plain square-cornered glow sitting incongruously beside one.
+
+/// How far a window's drop shadow extends past its geometry on each side.
+///
+/// `24`, not the original `12`: reported live as srdwm's own shadow
+/// reading as a thin, tight dark line rather than the soft, generously-
+/// sized glow real macOS windows have - doubled, and paired with
+/// `shadow_bitmap`'s own falloff moving from a plain linear ramp to an
+/// eased (`smoothstep`) one, which reads as noticeably softer at the same
+/// pixel budget even without a real blur primitive to work with.
+pub const SHADOW_SIZE: u32 = 24;
+
+/// The shadow's darkest alpha, right at the window's own edge - out of
+/// 255. Deliberately subtle (Nord/GNOME-default territory, not a heavy
+/// drop shadow): this compositor has no blur primitive to soften it with
+/// (see `shadow_bitmap`'s own doc comment), so a strong value would read as
+/// a hard dark ring rather than a shadow. This is the *focused*-window
+/// value - see `shadow_bitmap`'s own `max_alpha` parameter for why an
+/// unfocused window doesn't just reuse it unconditionally.
+pub(crate) const SHADOW_MAX_ALPHA: u8 = 90;
+
+/// `geometry` expanded by [`SHADOW_SIZE`] on every side - the full bounding
+/// box [`shadow_bitmap`] rasterises into, and where the caller positions it
+/// (top-left corner at `(geometry.x - SHADOW_SIZE, geometry.y - SHADOW_SIZE)`).
+pub fn shadow_rect(geometry: srdwm_core::Rect) -> srdwm_core::Rect {
+ let s = SHADOW_SIZE as i32;
+ srdwm_core::Rect::new(geometry.x - s, geometry.y - s, geometry.width + SHADOW_SIZE * 2, geometry.height + SHADOW_SIZE * 2)
+}
+
+/// Renders a window's drop shadow as a BGRA8 bitmap: black at an alpha that
+/// falls off linearly from [`SHADOW_MAX_ALPHA`] right at the window's own
+/// edge to fully transparent [`SHADOW_SIZE`] pixels out. `win_width`/
+/// `win_height` are the window's own footprint (`geometry`, border strips
+/// included if any - whatever the caller already draws as opaque); the
+/// returned bitmap is `shadow_rect`'s size, `SHADOW_SIZE` larger on every
+/// side.
+///
+/// Not a true Gaussian blur - no blur primitive is available without a GPU
+/// shader (the udev backend's `PixmanRenderer` is software-only) or a new
+/// image-processing dependency - so this is a stepless *linear* falloff
+/// using Chebyshev (square-ring) distance from the window's edge rather
+/// than a rounded/radial one, cheap enough to rebuild on every resize (see
+/// the caller for when that is) without a per-pixel sqrt. Reads as "soft
+/// enough" at the sizes a titlebar-height window actually uses, the same
+/// "approximate cutoff over true anti-aliasing" trade-off `corners::round_
+/// top_corners` already makes for corners.
+///
+/// The region directly under the window itself (`dist == 0` below) is left
+/// fully transparent rather than filled - harmless either way since the
+/// window's own border/titlebar/content always draws over it, but skipping
+/// it is one less branch of work for the common case (a window with no
+/// occluders in front of it, so most of the bitmap's interior never
+/// contributes a visible pixel).
+///
+/// `max_alpha` - the shadow's own darkest value, right at the window's
+/// edge - is a parameter rather than always `SHADOW_MAX_ALPHA`, so a
+/// caller can dim an *unfocused* window's shadow the same way `theme.
+/// border.inactive_dim` already dims an unfocused window's border colour
+/// (see `effective_border_color`). Real desktop convention, not invented
+/// here: Hyprland's own `decoration:shadow` config exposes `color` and
+/// `color_inactive` as two separate values specifically for this, common
+/// user configs going as far as a fully transparent `color_inactive` (no
+/// shadow at all once a window loses focus) - confirmed via Hyprland's
+/// own wiki, not assumed. `redraw_decoration_buffer` reuses `theme.
+/// border_inactive_dim` for this rather than adding a second, separately
+/// configurable factor: both are "how much does losing focus fade this
+/// window's own chrome", and this codebase already has a user-tunable
+/// answer to that question.
+pub fn shadow_bitmap(win_width: u32, win_height: u32, radius: u32, max_alpha: u8) -> Vec<u8> {
+ let (win_width, win_height) = (win_width.max(1), win_height.max(1));
+ let width = win_width + SHADOW_SIZE * 2;
+ let height = win_height + SHADOW_SIZE * 2;
+ // Clamped the same way `corners::round_top_corners`/`round_bottom_
+ // corners` clamp their own radius against the buffer they're cutting --
+ // a radius that would eat more than half of either the window's own
+ // width or height isn't geometrically meaningful.
+ let radius = radius.min(win_width / 2).min(win_height / 2);
+ let mut buf = vec![0u8; (width * height * 4) as usize];
+ for y in 0..height {
+ let dy = edge_distance(y, SHADOW_SIZE, win_height);
+ // The widest a row can still possibly contribute a visible pixel:
+ // a corner-quadrant pixel's distance is `sqrt(qx^2 + qy^2) -
+ // radius` where `qy = dy - radius`, and that can still be `<=
+ // SHADOW_SIZE` (this function's own cutoff below) even with
+ // `qx == 0`, i.e. up to `dy == SHADOW_SIZE + 2 * radius` - not
+ // just `SHADOW_SIZE + radius`, which would cut off real corner
+ // pixels a few rows early.
+ if dy > SHADOW_SIZE + 2 * radius {
+ continue;
+ }
+ for x in 0..width {
+ let dx = edge_distance(x, SHADOW_SIZE, win_width);
+ let dist = rounded_edge_distance(dx, dy, radius);
+ if dist == 0 || dist > SHADOW_SIZE {
+ continue;
+ }
+ // Eased (`smoothstep`), not a plain linear ramp - the same
+ // curve `apply_corner_mask`/`fill_button_dot` already use for
+ // their own anti-aliased edges, applied here across the whole
+ // shadow's width instead of a 2px antialiasing band. A linear
+ // falloff reads as a visible ring with a hard-ish inner edge
+ // even when fully transparent at both ends; easing both ends
+ // of the same 0..=1 range softens the transition into and out
+ // of the shadow without needing a real blur primitive.
+ let t = dist as f32 / SHADOW_SIZE as f32;
+ let eased = 1.0 - (t * t * (3.0 - 2.0 * t));
+ let alpha = (max_alpha as f32 * eased).round() as u8;
+ if alpha == 0 {
+ continue;
+ }
+ let i = ((y * width + x) * 4) as usize;
+ // Premultiplied BGRA, but the colour is black (0, 0, 0) - a
+ // premultiplied black pixel is just (0, 0, 0, alpha) at any
+ // alpha, so there's no separate multiply step needed here.
+ buf[i + 3] = alpha;
+ }
+ }
+ buf
+}
+
+/// `edge_distance`'s corner-aware version: `dx`/`dy` are already `edge_
+/// distance`'s own plain per-axis distances past the window's true edge
+/// (`0` on either axis means "not in a corner quadrant at all" - directly
+/// above/below/left/right of the window, or inside it) - this only
+/// changes what happens where *both* are positive, i.e. genuinely outside
+/// the window on both axes at once. Along a flat edge, a rounded rect's
+/// boundary is identical to a square one's (rounding only touches the
+/// corners), so the plain `max(dx, dy)` this replaces was already correct
+/// there and stays correct here too.
+///
+/// Without this, the shadow was a plain square-cornered falloff (`shadow_
+/// bitmap`'s own historical doc comment called this out as a deliberate
+/// Chebyshev-not-radial simplification - reasonable for a soft blur where
+/// nothing else in the frame gives the eye a hard edge to compare against,
+/// but wrong once the window it belongs to has a *visibly* rounded corner
+/// right next to it) - confirmed live via a real screenshot at actual
+/// render resolution: the shadow's own corner cut a hard diagonal well
+/// outside the window's own curve, plainly a different, unrelated shape
+/// sitting right beside it.
+///
+/// The corner-quadrant formula: place the window's true (sharp) corner at
+/// the origin, with the window occupying the quadrant behind it - `dx`/
+/// `dy` are how far past that origin the shadow pixel sits on each axis. A
+/// *rounded* corner's circle sits centred `radius` pixels in from that
+/// origin on both axes, i.e. at `(-radius, -radius)`. Straight-line
+/// distance from the pixel to that centre is `sqrt((dx + radius)^2 + (dy +
+/// radius)^2)`; subtracting `radius` converts "distance to the circle's
+/// centre" into "distance to the circle's own boundary", which is what a
+/// real rounded corner's curve actually traces. (At `dx = dy = 0` - the
+/// old sharp corner's own tip - this correctly comes out positive, not
+/// `0`: the rounded window's boundary has curved away from that point
+/// entirely, so it's already outside the window, not sitting right on its
+/// edge the way a real square corner's tip would be.)
+pub(super) fn rounded_edge_distance(dx: u32, dy: u32, radius: u32) -> u32 {
+ // `radius == 0` (nothing to round - kept byte-identical to the
+ // pre-existing Chebyshev-everywhere behaviour, not just "close
+ // enough": true Euclidean distance to a sharp corner *point* differs
+ // from Chebyshev distance to it even without any rounding, and this
+ // function must not change a square window's own shadow shape) or a
+ // genuine flat-edge point (`dx == 0` xor `dy == 0` - rounding never
+ // touches these, only the four corners) both use plain Chebyshev
+ // distance, unchanged from before this function existed.
+ if radius == 0 || (dx == 0) != (dy == 0) {
+ return dx.max(dy);
+ }
+ let (ex, ey) = (dx as f32 + radius as f32, dy as f32 + radius as f32);
+ let corner_dist = (ex * ex + ey * ey).sqrt() - radius as f32;
+ corner_dist.max(0.0).round() as u32
+}
+
+/// How far outside `[margin, margin + extent)` - the window's own span
+/// along one axis, inside the shadow's `margin`-pixel border on each side
+/// - position `pos` sits, in pixels. `0` anywhere inside that span
+/// (including exactly on its edge).
+fn edge_distance(pos: u32, margin: u32, extent: u32) -> u32 {
+ if pos < margin {
+ margin - pos
+ } else if pos >= margin + extent {
+ pos - (margin + extent) + 1
+ } else {
+ 0
+ }
+}
diff --git a/crates/wayland/src/decoration/tests.rs b/crates/wayland/src/decoration/tests.rs
new file mode 100644
index 0000000..0cc5d45
--- /dev/null
+++ b/crates/wayland/src/decoration/tests.rs
@@ -0,0 +1,756 @@
+use super::*;
+use super::border::*;
+use super::buttons::*;
+use super::color::*;
+use super::corners::*;
+use super::font::*;
+use super::shadow::*;
+use super::titlebar::*;
+
+#[test]
+fn border_strips_surround_geometry_without_overlapping_it() {
+ let geom = srdwm_core::Rect::new(100, 100, 200, 150);
+ let [top, bottom, left, right] = border_strips(geom, 3);
+ // Every strip's own rect must stay entirely outside `geom` - these
+ // are meant to frame the window, not clip into its own titlebar or
+ // content.
+ assert_eq!(top, srdwm_core::Rect::new(97, 97, 206, 3));
+ assert_eq!(bottom, srdwm_core::Rect::new(97, 250, 206, 3));
+ assert_eq!(left, srdwm_core::Rect::new(97, 100, 3, 150));
+ assert_eq!(right, srdwm_core::Rect::new(300, 100, 3, 150));
+}
+
+#[test]
+fn shadow_rect_expands_geometry_by_shadow_size_on_every_side() {
+ let geom = srdwm_core::Rect::new(100, 100, 200, 150);
+ let s = shadow_rect(geom);
+ assert_eq!(s, srdwm_core::Rect::new(100 - SHADOW_SIZE as i32, 100 - SHADOW_SIZE as i32, 200 + SHADOW_SIZE * 2, 150 + SHADOW_SIZE * 2));
+}
+
+#[test]
+fn shadow_bitmap_is_the_expected_size_and_transparent_under_the_window() {
+ let buf = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let width = 40 + SHADOW_SIZE * 2;
+ let height = 20 + SHADOW_SIZE * 2;
+ assert_eq!(buf.len(), (width * height * 4) as usize);
+ // Dead center is inside the window's own footprint - must stay
+ // fully transparent, since the window's own content draws over it.
+ let mid = ((height / 2) * width + width / 2) * 4;
+ assert_eq!(buf[mid as usize + 3], 0);
+}
+
+#[test]
+fn shadow_bitmap_is_darkest_right_at_the_window_edge_and_fades_outward() {
+ let buf = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let width = (40 + SHADOW_SIZE * 2) as usize;
+ // Walking straight up from the window's horizontal center, from one
+ // pixel above its top edge (row SHADOW_SIZE - 1) out to the shadow's
+ // own outer edge (row 0): alpha must start near SHADOW_MAX_ALPHA and
+ // strictly decrease to 0.
+ let x = width / 2;
+ let mut last_alpha = 255u8;
+ for row in (0..SHADOW_SIZE as usize).rev() {
+ let i = (row * width + x) * 4;
+ let alpha = buf[i + 3];
+ assert!(alpha <= last_alpha, "alpha rose from {last_alpha} to {alpha} moving outward at row {row}");
+ last_alpha = alpha;
+ }
+ assert_eq!(last_alpha, 0, "outermost row must be fully transparent");
+}
+
+#[test]
+fn shadow_falloff_is_eased_not_linear() {
+ // The actual visual change: a plain linear ramp drops the same
+ // amount of alpha every pixel, which reads as a visible ring with
+ // a hard-ish inner edge even though it's transparent at both
+ // ends. An eased (smoothstep) curve drops *less* per pixel near
+ // both ends and *more* in the middle - this is what distinguishes
+ // it from linear at the byte level, not just "still monotonic".
+ let buf = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let width = (40 + SHADOW_SIZE * 2) as usize;
+ let x = width / 2;
+ let alpha_at = |dist_from_edge: u32| {
+ let row = SHADOW_SIZE - dist_from_edge;
+ buf[(row as usize * width + x) * 4 + 3] as i32
+ };
+ // Step near the shadow's own inner edge (dist 1 -> 2) versus a
+ // step through the middle (dist SHADOW_SIZE/2 -> SHADOW_SIZE/2+1):
+ // eased must drop less near the edge than a linear ramp would
+ // (linear drops `max_alpha / SHADOW_SIZE` every single step,
+ // uniformly) - the middle step must drop more than that same
+ // uniform linear amount to compensate, since both curves start
+ // and end at the same two values.
+ let linear_step = SHADOW_MAX_ALPHA as i32 / SHADOW_SIZE as i32;
+ let near_edge_drop = alpha_at(1) - alpha_at(2);
+ let middle_drop = alpha_at(SHADOW_SIZE / 2) - alpha_at(SHADOW_SIZE / 2 + 1);
+ assert!(near_edge_drop < linear_step, "near-edge step ({near_edge_drop}) should be gentler than a linear ramp's own uniform step ({linear_step})");
+ assert!(middle_drop > near_edge_drop, "the steepest part of an eased curve should be in the middle ({middle_drop}), not at the edge ({near_edge_drop})");
+}
+
+#[test]
+fn a_lower_max_alpha_produces_a_strictly_fainter_shadow_throughout() {
+ // Locks in the `max_alpha` parameter's actual effect - an
+ // unfocused window's dimmed shadow (see `redraw_decoration_
+ // buffer`'s own call site) must never be darker than the focused
+ // one at any pixel, not just "different".
+ let focused = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let dimmed = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA / 2);
+ assert_ne!(focused, dimmed, "sanity: halving max_alpha must actually change the bitmap");
+ for (f, d) in focused.chunks_exact(4).zip(dimmed.chunks_exact(4)) {
+ assert!(d[3] <= f[3], "dimmed alpha {} must never exceed focused alpha {}", d[3], f[3]);
+ }
+}
+
+#[test]
+fn rounded_shadow_corner_tip_is_further_than_the_square_corners_own() {
+ // Regression coverage for the actual live bug: a window's shadow
+ // used to always taper with plain Chebyshev (square) distance, so
+ // its own corner cut a hard diagonal well outside a *rounded*
+ // window's real curve, sitting there as a visibly different,
+ // unrelated shape - confirmed live via a real screenshot at
+ // actual render resolution. At the window's old sharp-corner tip
+ // (dx = dy = 0), the rounded window's boundary has curved away
+ // entirely, so this point must read as *further* from the window
+ // than plain Chebyshev's `0` - not `0`, which would mean "right on
+ // the window's own edge", true only for a genuinely square corner.
+ let radius = 6;
+ assert_eq!(rounded_edge_distance(0, 0, 0), 0, "sanity: radius 0 must match plain Chebyshev distance exactly");
+ let rounded = rounded_edge_distance(0, 0, radius);
+ assert!(rounded > 0, "the old sharp corner's own tip must be outside a rounded window, not sitting right on its edge");
+ // Exact value, worked out by hand: distance from the tip to the
+ // rounding circle's centre (radius, radius) is radius*sqrt(2), so
+ // distance to the circle's own boundary is radius*(sqrt(2) - 1).
+ let expected = (radius as f32 * (2.0f32.sqrt() - 1.0)).round() as u32;
+ assert_eq!(rounded, expected);
+}
+
+#[test]
+fn rounded_edge_distance_matches_plain_chebyshev_along_a_flat_edge() {
+ // Rounding only ever touches the four corners - directly above,
+ // below, left or right of the window (exactly one of dx/dy is 0),
+ // a rounded rect's boundary is identical to a square one's.
+ for (dx, dy) in [(5, 0), (0, 5), (12, 0), (0, 12)] {
+ assert_eq!(rounded_edge_distance(dx, dy, 6), dx.max(dy), "flat-edge point ({dx}, {dy}) must use plain Chebyshev distance, not the corner formula");
+ }
+}
+
+#[test]
+fn shadow_bitmap_corner_is_softer_than_a_square_windows_when_rounded() {
+ let square = shadow_bitmap(40, 40, 0, SHADOW_MAX_ALPHA);
+ let rounded = shadow_bitmap(40, 40, 6, SHADOW_MAX_ALPHA);
+ let width = (40 + SHADOW_SIZE * 2) as usize;
+ // The old sharp corner's own tip: `SHADOW_SIZE` pixels in from the
+ // bitmap's own outer edge on both axes, i.e. exactly at row/column
+ // `SHADOW_SIZE` - where `dx == dy == 0` in `edge_distance`'s own
+ // terms, the window's true corner point. A square window's shadow
+ // is at its darkest possible value there (`dist == 0`, right on
+ // the window's own edge); a rounded window's real boundary has
+ // already curved away from that exact point, so it must read
+ // strictly lighter there (a real positive distance means a
+ // partially-faded, not maximum, alpha) - not identical, which is
+ // what the bug this fixes looked like.
+ let tip = ((SHADOW_SIZE as usize) * width + SHADOW_SIZE as usize) * 4;
+ assert_eq!(square[tip + 3], 0, "sanity: a square window's shadow is fully suppressed right at its own corner");
+ assert!(rounded[tip + 3] > 0, "a rounded window's shadow must actually draw at the old sharp-corner tip, since that point is genuinely outside the rounded boundary");
+}
+
+#[test]
+fn fills_background_when_no_text() {
+ let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ assert_eq!(buf.len(), 40 * 20 * 4);
+ // Center, not (0,0): the top-left pixel is inside the rounded
+ // corner `round_top_corners` clips away, so it's transparent by
+ // design - see `corners_are_clipped_but_the_middle_is_not` below.
+ let mid = ((20 / 2) * 40 + 40 / 2) * 4;
+ assert_eq!(&buf[mid..mid + 4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255));
+}
+
+#[test]
+fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() {
+ // Regression test for a bug where every drawn icon was one full
+ // button-width left of where a click on it actually landed: the
+ // visible "X" triggered Maximize, the visible square triggered
+ // Minimize, and the true Close hit-zone (the rightmost
+ // TITLEBAR_HEIGHT-wide band) was blank. `button_box`'s
+ // `right_offset` must put each icon in the same square
+ // `ResizeEdge::hit_test` assigns to it - checked here by picking
+ // the centre pixel of each drawn icon's square and confirming
+ // `hit_test` reports the matching button for that same point.
+ let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let frame = srdwm_core::Rect::new(0, 0, width, height);
+ let (width, height) = (width as usize, height as usize);
+
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ let pitch = srdwm_core::BUTTON_PITCH as usize;
+ let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ for (right_offset, expected) in [(margin, srdwm_core::TitlebarHit::Close), (margin + pitch, srdwm_core::TitlebarHit::Maximize), (margin + pitch * 2, srdwm_core::TitlebarHit::Minimize)] {
+ let (x0, y0, x1, y1) = button_box(width, height, right_offset, false, BUTTON_MARGIN);
+ let drawn = (y0..=y1).any(|y| (x0..=x1).any(|x| buf[(y as usize * width + x as usize) * 4..(y as usize * width + x as usize) * 4 + 4] != bg_bytes));
+ assert!(drawn, "expected some drawn icon pixel inside the right_offset={right_offset} square");
+ let cx = (x0 + x1) / 2;
+ let cy = (y0 + y1) / 2;
+ assert_eq!(
+ srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false),
+ Some(expected),
+ "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}"
+ );
+ }
+}
+
+#[test]
+fn a_dialog_draws_only_close_and_never_a_coloured_traffic_light() {
+ // Requested directly: "dialog windows shouldn't have maximize/
+ // minimize buttons... don't use traffic lights there ever."
+ let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ // `traffic_lights = true` passed in deliberately - `is_dialog`
+ // must override it regardless of what the active theme otherwise
+ // uses everywhere else. `glyph_always = true` so Close's own X is
+ // visible without needing a live hover to check it landed.
+ let dialog = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, true);
+ let normal = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, false);
+ let (w, h) = (width as usize, height as usize);
+
+ // Where a normal (non-dialog) titlebar draws Maximize (offset
+ // `BUTTON_PITCH`) must be untouched background on the dialog --
+ // that button was never drawn there at all, not just hit-test-
+ // unreachable.
+ let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ let maximize_box = button_box(w, h, margin + srdwm_core::BUTTON_PITCH as usize, false, BUTTON_MARGIN);
+ let maximize_drawn_on_normal =
+ (maximize_box.1..=maximize_box.3).any(|y| (maximize_box.0..=maximize_box.2).any(|x| normal[(y as usize * w + x as usize) * 4..(y as usize * w + x as usize) * 4 + 4] != bg_bytes));
+ assert!(maximize_drawn_on_normal, "sanity: a normal titlebar really does draw something at the Maximize slot");
+ let maximize_drawn_on_dialog =
+ (maximize_box.1..=maximize_box.3).any(|y| (maximize_box.0..=maximize_box.2).any(|x| dialog[(y as usize * w + x as usize) * 4..(y as usize * w + x as usize) * 4 + 4] != bg_bytes));
+ assert!(!maximize_drawn_on_dialog, "a dialog must draw nothing at all at the Maximize slot");
+
+ // Close's own box: no coloured fill (a flat, opaque red/near-red
+ // dot, same family `TRAFFIC_LIGHT_CLOSE` produces) anywhere in it
+ // - only the plain-glyph X, drawn in `fg`, may appear.
+ let close_box = button_box(w, h, margin, false, BUTTON_MARGIN);
+ for y in close_box.1..=close_box.3 {
+ for x in close_box.0..=close_box.2 {
+ let i = (y as usize * w + x as usize) * 4;
+ let px = &dialog[i..i + 4];
+ // BGRA - a red-family traffic light has a strongly
+ // dominant red channel (index 2) well above both blue and
+ // green; the plain glyph (`fg`, near-white/grey) and the
+ // untouched background do not.
+ let (b, g, r) = (px[0] as i32, px[1] as i32, px[2] as i32);
+ assert!(!(r > g + 40 && r > b + 40), "close button pixel at ({x},{y}) reads as a coloured traffic light: bgra={px:?}");
+ }
+ }
+}
+
+#[test]
+fn drawing_title_changes_some_pixels_when_font_available() {
+ if find_system_font().is_none() {
+ eprintln!("skipping: no system font found in this sandbox");
+ return;
+ }
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let buf = render_titlebar(200, 30, "Terminal", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ let changed = buf.chunks_exact(4).any(|px| px != bg_bytes);
+ assert!(changed, "expected at least one pixel to differ from the background once text is drawn");
+}
+
+#[test]
+fn centered_title_starts_further_right_than_left_aligned() {
+ if find_system_font().is_none() {
+ eprintln!("skipping: no system font found in this sandbox");
+ return;
+ }
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let (width, height) = (60u32, 30u32);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ // No buttons at this width (`button_count` gates on
+ // `width >= BUTTON_CLUSTER_MARGIN + BUTTON_PITCH * 3`), so
+ // `text_limit` is the full width and a short title has real room to
+ // actually move when centered - otherwise this could pass even
+ // with centering silently broken.
+ assert!(width < srdwm_core::BUTTON_CLUSTER_MARGIN + srdwm_core::BUTTON_PITCH * 3, "sanity: this fixture must have no button squares eating into text_limit");
+ // Only rows clear of `round_top_corners`' own clipping (confined to
+ // `CORNER_RADIUS` rows from the top - see its doc comment) --
+ // otherwise a clipped-to-transparent corner pixel (a different
+ // byte pattern than `bg_bytes`) registers as "ink" at column 0
+ // regardless of where the text actually starts.
+ let scan_rows = CORNER_RADIUS as usize..height as usize;
+ let leftmost_ink_column = |buf: &[u8]| -> Option<usize> {
+ (0..width as usize).find(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes))
+ };
+ let left = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let centered = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, false, false, None, true, false);
+ let left_start = leftmost_ink_column(&left).expect("left-aligned title must draw some ink");
+ let centered_start = leftmost_ink_column(&centered).expect("centered title must draw some ink");
+ assert!(centered_start > left_start, "a short title centered in a wide titlebar must start well to the right of the left-aligned version (left starts at {left_start}, centered at {centered_start})");
+}
+
+#[test]
+fn centered_title_ignores_the_button_reservation_and_centers_on_the_whole_width() {
+ if find_system_font().is_none() {
+ eprintln!("skipping: no system font found in this sandbox");
+ return;
+ }
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ // Wide enough for the 3-button reservation (`width >=
+ // BUTTON_CLUSTER_MARGIN + BUTTON_PITCH * 3`) on the left - this is
+ // exactly the case that used to center in the narrower `text_start
+ // ..text_limit` span left over after the buttons, landing off the
+ // window's true center instead of at `width / 2`.
+ let (width, height) = (300u32, 30u32);
+ assert!(width >= srdwm_core::BUTTON_CLUSTER_MARGIN + srdwm_core::BUTTON_PITCH * 3, "sanity: this fixture must have button squares eating into text_start");
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ let scan_rows = CORNER_RADIUS as usize..height as usize;
+ // Scan only from `text_start` (the button reservation's own right
+ // edge) onward - the centering formula never places text left of
+ // that regardless, and scanning from column 0 would also pick up
+ // the button dots themselves (real, different-colour ink), not
+ // just the title text this is actually trying to measure.
+ let button_reservation = srdwm_core::BUTTON_CLUSTER_MARGIN as usize + srdwm_core::BUTTON_PITCH as usize * 3;
+ let ink_columns = |buf: &[u8]| -> Vec<usize> {
+ (button_reservation..width as usize).filter(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes)).collect()
+ };
+ let buf = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, true, false, None, true, false);
+ let columns = ink_columns(&buf);
+ let (first, last) = (*columns.first().expect("centered title must draw some ink"), *columns.last().unwrap());
+ let midpoint = (first + last) as f32 / 2.0;
+ let old_buggy_midpoint = 195.0; // center of the 90..300 span the button reservation used to leave
+ assert!((midpoint - width as f32 / 2.0).abs() < 5.0, "title midpoint {midpoint} should land within a few px of the true window center ({}), not the button-adjusted span's own center ({old_buggy_midpoint})", width as f32 / 2.0);
+}
+
+#[test]
+fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() {
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ for (i, px) in buf.chunks_exact(4).enumerate() {
+ let (x, y) = (i % width as usize, i / width as usize);
+ let in_top_left = x < CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
+ let in_top_right = x >= width as usize - CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
+ if !in_top_left && !in_top_right {
+ assert_eq!(px, bg_bytes, "unexpected non-background pixel at ({x}, {y})");
+ }
+ }
+}
+
+#[test]
+fn corners_are_clipped_but_the_middle_is_not() {
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ // The very corner pixel is well outside the quarter-circle at any
+ // sane radius - fully clipped.
+ assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be transparent");
+ assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be transparent");
+ // Bottom corners are deliberately left square (see the function's
+ // doc comment: the titlebar's bottom edge meets client content,
+ // which can't be clipped the same way).
+ assert_eq!(alpha_at(0, height as usize - 1), 255, "bottom-left must stay square");
+ assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255, "bottom-right must stay square");
+ // Centre is nowhere near either corner circle - untouched.
+ assert_eq!(alpha_at(width as usize / 2, height as usize / 2), 255);
+}
+
+#[test]
+fn clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha() {
+ // Regression test: `round_top_corners` used to zero only the alpha
+ // byte of a clipped pixel, leaving the opaque background RGB behind
+ // it untouched. This buffer is `Fourcc::Argb8888`, which both
+ // Wayland/`wl_shm` and Pixman treat as premultiplied - Pixman's own
+ // `OVER` compositing (`result = src + dst * (1 - src_alpha)`) does
+ // not treat `alpha=0, rgb=<something>` as "contributes nothing": it
+ // adds that stale, un-premultiplied `rgb` straight through, so the
+ // "clipped" corner still rendered fully opaque and every window's
+ // top corners read as square regardless of a nonzero radius --
+ // confirmed live, pixel-by-pixel, zero transparency anywhere in a
+ // real window's corner. A genuinely transparent premultiplied pixel
+ // is `(0, 0, 0, 0)` in every channel, not just alpha.
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let px_at = |x: usize, y: usize| &buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4];
+ assert_eq!(px_at(0, 0), [0, 0, 0, 0], "top-left corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
+ assert_eq!(px_at(width as usize - 1, 0), [0, 0, 0, 0], "top-right corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
+}
+
+#[test]
+fn round_corners_false_leaves_the_top_corners_square() {
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255, "top-left corner should stay square when round_corners is false");
+ assert_eq!(alpha_at(width as usize - 1, 0), 255, "top-right corner should stay square when round_corners is false");
+}
+
+#[test]
+fn brighten_lightens_every_channel_including_a_saturated_one() {
+ let (r, g, b) = brighten((0x28, 0xc8, 0x00));
+ assert!(r > 0x28, "already-bright channel must still move toward white, not stay put");
+ assert!(g > 0xc8);
+ assert!(b > 0x00, "a fully-unsaturated (0) channel must still brighten, not stay clamped at 0");
+}
+
+#[test]
+fn hovering_the_close_button_brightens_only_that_dot() {
+ // The actual feature this is for: hovering one button must change
+ // *that* button's colour and leave the other two exactly as they
+ // were - not brighten all three, and not brighten the wrong one.
+ let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let plain = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let close_hovered = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, Some((srdwm_core::TitlebarHit::Close, 255)), false, false, false, None, true, false);
+ let frame = srdwm_core::Rect::new(0, 0, width, height);
+ let (w, h) = (width as usize, height as usize);
+ let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ let close_box = button_box(w, h, margin, false, BUTTON_MARGIN);
+ let minimize_box = button_box(w, h, margin + srdwm_core::BUTTON_PITCH as usize * 2, false, BUTTON_MARGIN);
+ let center_px = |buf: &[u8], (x0, y0, x1, y1): (i32, i32, i32, i32)| {
+ let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
+ let i = (cy as usize * w + cx as usize) * 4;
+ buf[i..i + 4].to_vec()
+ };
+ assert_ne!(center_px(&plain, close_box), center_px(&close_hovered, close_box), "hovering close must actually change its own dot's colour");
+ assert_eq!(center_px(&plain, minimize_box), center_px(&close_hovered, minimize_box), "hovering close must not also change the minimize dot");
+ // Sanity: hit_test must actually route a click at this same centre
+ // point to Close, or this test would be checking a hover state
+ // that a real pointer could never reach in the first place.
+ let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2);
+ assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false), Some(srdwm_core::TitlebarHit::Close));
+}
+
+#[test]
+fn button_dot_has_a_glossy_highlight_toward_the_upper_left_and_shadow_toward_the_lower_right() {
+ // The actual visual change requested: a flat-filled dot read as
+ // noticeably flatter than real macOS's own traffic lights (see
+ // `glossy_shade`'s own doc comment, referenced against a real
+ // screenshot). This locks in the shape of that gradient, not just
+ // that pixels differ from the center - upper-left must be
+ // brighter than center, lower-right must be darker, matching a
+ // light source up-and-to-the-left.
+ let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let (w, h) = (width as usize, height as usize);
+ let close_box = button_box(w, h, srdwm_core::BUTTON_CLUSTER_MARGIN as usize, false, BUTTON_MARGIN);
+ let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2);
+ let radius = ((close_box.2 - close_box.0).min(close_box.3 - close_box.1) as f32 / 2.0) * 0.6;
+ let px = |x: i32, y: i32| -> u32 {
+ let i = (y as usize * w + x as usize) * 4;
+ // BGRA - sum the colour channels, ignore alpha (always 255
+ // here), so "brighter"/"darker" reads as a plain luma proxy.
+ buf[i] as u32 + buf[i + 1] as u32 + buf[i + 2] as u32
+ };
+ let center = px(cx, cy);
+ let highlight = px(cx - radius.round() as i32, cy - radius.round() as i32);
+ let shadow = px(cx + radius.round() as i32, cy + radius.round() as i32);
+ assert!(highlight > center, "upper-left of the dot ({highlight}) should be brighter than its centre ({center})");
+ assert!(shadow < center, "lower-right of the dot ({shadow}) should be darker than its centre ({center})");
+}
+
+#[test]
+fn border_top_rounds_its_own_top_corners_to_match_the_titlebar() {
+ // Regression coverage for the "not all window borders are rounded"
+ // report: a bordered window's titlebar used to render with
+ // `round_corners = false` specifically to avoid clashing with this
+ // strip's square corners. Now that this strip rounds too, that
+ // workaround is gone (`render_titlebar` is always called with
+ // `true`) - this just confirms the strip actually does what that
+ // change now depends on.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness) = (60, 2);
+ let buf = render_border_top(width, thickness, color, CORNER_RADIUS);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be clipped");
+ assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be clipped");
+ // The strip is only `thickness` pixels tall, but its curve is the
+ // titlebar's own radius continued outward (`radius + thickness`,
+ // see `render_border_top`) - so the horizontal centre, well clear
+ // of either corner's cut zone, must stay opaque regardless of how
+ // much taller than the strip that combined radius is.
+ assert_eq!(alpha_at(width as usize / 2, thickness as usize - 1), 255, "centre of the strip must stay opaque");
+}
+
+#[test]
+fn border_top_and_titlebar_corners_meet_without_a_seam() {
+ // Regression coverage for a *different* bug than the one this test
+ // used to check (see git history for the old
+ // `border_top_corner_curve_matches_the_titlebars_larger_radius`):
+ // an earlier version of this pair rounded the border strip to
+ // `radius + thickness` while the titlebar rounded to plain
+ // `radius` - two circles sharing a centre but with *different*
+ // radii, which do not meet smoothly at any boundary between them.
+ // Confirmed live, screenshotted at actual render resolution: a
+ // hard stepped notch right where a decorated window's border met
+ // its titlebar, not a continuous curve. Both now draw their own
+ // slice of the exact same circle (`round_top_corners`'s own doc
+ // comment has the full geometry) - this checks that promise
+ // directly, by rendering both real bitmaps with the same
+ // parameters a live window actually uses and comparing the alpha
+ // at the border's last row against the titlebar's first row,
+ // immediately below it.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, radius) = (60, 4, 6);
+ let border = render_border_top(width, thickness, color, radius);
+ let titlebar = render_titlebar(width, 24, "", color, (0xff, 0xff, 0xff), true, radius, thickness, true, None, false, false, false, None, true, false);
+ let border_alpha_at = |x: usize| border[((thickness as usize - 1) * width as usize + x) * 4 + 3];
+ let titlebar_alpha_at = |x: usize| titlebar[x * 4 + 3];
+ // Every column across the curve's actual reach, not just one
+ // sample point - a seam bug shows up as a jump at some columns
+ // and not others (the exact shape of the mismatch between two
+ // differently-sized circles), so checking only the corner pixel
+ // or only the centre could miss it entirely, the same way the
+ // original bug slipped past the test above it for months. Worked
+ // out by hand (see this fix's own commit) what the two designs
+ // actually produce at every column for radius=6/thickness=4: the
+ // old (`radius + thickness`-for-the-border) design jumped by as
+ // much as 187 out of 255, at *every* column past the first two;
+ // this design jumps by at most 70, confined to the two columns
+ // nearest the exact tip - an inherent limit of splitting one
+ // steep curve across two separately-rasterised bitmaps a single
+ // row apart, not a bug to chase further. `< 90` catches any
+ // regression back toward the old behaviour without demanding
+ // more precision than two 1px-apart raster buffers can give.
+ for x in 0..radius as usize + 2 {
+ let (b, t) = (border_alpha_at(x), titlebar_alpha_at(x));
+ let jump = (b as i32 - t as i32).abs();
+ assert!(jump < 90, "column {x}: border's last row (alpha={b}) and titlebar's first row (alpha={t}) must be close, not a sharp seam (jump={jump})");
+ }
+ // Past the tip's unavoidable steepness (columns 0-1 above), the
+ // curve should be genuinely, near-exactly continuous - both
+ // rows fully opaque by then for this radius/thickness, not just
+ // "close enough".
+ for x in 2..radius as usize + 2 {
+ let (b, t) = (border_alpha_at(x), titlebar_alpha_at(x));
+ let jump = (b as i32 - t as i32).abs();
+ assert!(jump <= 2, "column {x}: past the corner tip the seam should be essentially exact, not just under the looser tip tolerance (jump={jump})");
+ }
+}
+
+#[test]
+fn border_top_visible_rows_decorated_shows_the_whole_taller_buffer() {
+ let (row0, rows, shift) = border_top_visible_rows(true, 4, 11);
+ assert_eq!((row0, rows, shift), (0, 11, 0), "decorated: full max(border_width, radius) buffer, unshifted");
+}
+
+#[test]
+fn border_top_visible_rows_undecorated_crops_to_just_the_nominal_thickness() {
+ // The actual regression this exists for: reported live as a
+ // border-coloured wedge cut into a real undecorated Firefox
+ // window's top-left corner, confirmed via a real screenshot to be
+ // neither Firefox's own rendering nor the content-mask feature --
+ // this buffer's own titlebar-band-only extra rows, painted
+ // straight onto real content instead, were the only remaining
+ // source.
+ let (row0, rows, shift) = border_top_visible_rows(false, 4, 11);
+ assert_eq!((row0, rows, shift), (0, 4, 0), "undecorated: cropped to exactly border_width rows, still starting at row 0 (this strip grows downward)");
+}
+
+#[test]
+fn border_top_visible_rows_radius_no_bigger_than_border_is_a_no_op_either_way() {
+ // When the buffer was never grown taller than `border_width` in
+ // the first place (radius <= border_width), decorated and
+ // undecorated must agree - there are no "extra" rows to disagree
+ // about.
+ assert_eq!(border_top_visible_rows(true, 6, 4), border_top_visible_rows(false, 6, 4));
+}
+
+#[test]
+fn border_bottom_visible_rows_decorated_shows_the_whole_taller_buffer_shifted_up() {
+ let (row0, rows, shift) = border_bottom_visible_rows(true, 4, 11);
+ assert_eq!((row0, rows, shift), (0, 11, 7), "decorated: full buffer, shifted up by extra = max(4,11) - 4 = 7");
+}
+
+#[test]
+fn border_bottom_visible_rows_undecorated_crops_to_the_buffers_last_rows_unshifted() {
+ // Same real bug as the top strip, confirmed on the same Firefox
+ // window's bottom-left corner via a real screenshot - this strip
+ // grows *upward* into content instead of downward, so the safe
+ // rows are the buffer's *last* `border_width` of them (starting at
+ // `row0 = extra`), not its first, and no position shift is needed
+ // once the extra rows themselves are never drawn.
+ let (row0, rows, shift) = border_bottom_visible_rows(false, 4, 11);
+ assert_eq!((row0, rows, shift), (7, 4, 0), "undecorated: last border_width rows only, starting past the 7-row extra, unshifted");
+}
+
+#[test]
+fn border_bottom_visible_rows_radius_no_bigger_than_border_is_a_no_op_either_way() {
+ assert_eq!(border_bottom_visible_rows(true, 6, 4), border_bottom_visible_rows(false, 6, 4));
+}
+
+#[test]
+fn border_top_extra_rows_are_transparent_outside_the_corners() {
+ // `render_border_bottom`'s mirror - see
+ // `border_bottom_extra_rows_are_transparent_outside_the_corners`'s
+ // doc comment for the full story (this is the same real,
+ // live-confirmed bug, the top-strip half of the pair).
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, radius) = (60, 2, 6);
+ let buf = render_border_top(width, thickness, color, radius);
+ let height = (thickness as usize).max(radius as usize);
+ assert_eq!(buf.len(), width as usize * height * 4, "buffer must actually be the taller max(thickness, radius) height");
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ // The strip's real thickness sits at the *top* here (rows grow
+ // downward into content, the mirror of the bottom strip growing
+ // upward) - rows 0..thickness must still be fully opaque in the
+ // middle, exactly as before this fix.
+ for y in 0..thickness as usize {
+ assert_eq!(alpha_at(width as usize / 2, y), 255, "row {y}: within the strip's real thickness, the middle column must stay opaque");
+ }
+ for y in thickness as usize..height {
+ assert_eq!(alpha_at(width as usize / 2, y), 0, "row {y}: middle column of an extra row must be transparent, not solid border colour");
+ }
+}
+
+#[test]
+fn border_top_curve_actually_closes_within_the_side_strips_own_width() {
+ // The actual, directly-observable bug this whole fix is for:
+ // confirmed live via pixel-level inspection of a real screenshot
+ // (not just reasoned about) that the horizontal top border segment
+ // only became opaque some ~20 columns in from the corner while the
+ // *flat, curve-blind* left/right strip only ever covers its own
+ // `border_width` columns - so with `radius` meaningfully larger
+ // than `border_width`, there was a real gap of bare background
+ // between them, at exactly the column range a real vertical border
+ // strip occupies. This checks that gap is actually closed: by this
+ // buffer's own last row (where the curve should have fully
+ // resolved to flat, for a `radius` that fits within the strip's
+ // half-width), the column right at the edge of where a
+ // `border_width`-wide side strip would sit must already be opaque.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, border_width, radius) = (60u32, 2u32, 3u32, 6u32);
+ let buf = render_border_top(width, thickness, color, radius);
+ let height = (thickness as usize).max(radius as usize);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(border_width as usize - 1, height - 1), 255, "the side strip's own rightmost column must be fully covered by the curve at the buffer's last row, not left as a gap");
+}
+
+#[test]
+fn border_bottom_rounds_its_own_bottom_corners() {
+ // `thickness` (2) < `CORNER_RADIUS` (6) here, same as the live
+ // theme defaults (border width 4, radius 6) - so the buffer is
+ // taller than `thickness`, and the true tip (fully clipped corner)
+ // sits at the buffer's own *last* row, not `thickness - 1` (see
+ // `render_border_bottom`'s doc comment for why the buffer grows at
+ // all).
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness) = (60, 2);
+ let buf = render_border_bottom(width, thickness, color, CORNER_RADIUS);
+ let height = (thickness as usize).max(CORNER_RADIUS as usize);
+ assert_eq!(buf.len(), width as usize * height * 4, "buffer must actually be the taller max(thickness, radius) height");
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ let tip_row = height - 1;
+ assert_eq!(alpha_at(0, tip_row), 0, "bottom-left corner pixel should be clipped");
+ assert_eq!(alpha_at(width as usize - 1, tip_row), 0, "bottom-right corner pixel should be clipped");
+ assert_eq!(alpha_at(width as usize / 2, tip_row), 255, "centre of the strip must stay opaque even at the tip row");
+}
+
+#[test]
+fn border_bottom_extra_rows_are_transparent_outside_the_corners() {
+ // Regression coverage for the real bug this pair of functions was
+ // fixed for: with `radius > thickness`, the strip's own curve
+ // didn't reach far enough to meet the (curve-blind, flat) side
+ // strips, leaving a wedge of bare background between them --
+ // confirmed live via pixel-level inspection of a real screenshot,
+ // not just reasoned about. The fix grows the buffer to
+ // `max(thickness, radius)` so the curve has room to fully resolve,
+ // but the *extra* rows (above the original `thickness`, since this
+ // strip grows upward into content - see `render_border_bottom`'s
+ // doc comment) must stay transparent in the middle (non-corner)
+ // columns, or they'd paint a solid border-coloured bar across
+ // whatever the content actually owns there.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, radius) = (60, 2, 6);
+ let buf = render_border_bottom(width, thickness, color, radius);
+ let height = (thickness as usize).max(radius as usize);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ for y in 0..height - thickness as usize {
+ assert_eq!(alpha_at(width as usize / 2, y), 0, "row {y}: middle column of an extra row must be transparent, not solid border colour");
+ }
+ // The original `thickness` rows (now at the *bottom* of the taller
+ // buffer) must still be the strip's real, fully opaque body in the
+ // middle - this fix must not have eaten into the strip's own
+ // legitimate thickness.
+ for y in height - thickness as usize..height {
+ assert_eq!(alpha_at(width as usize / 2, y), 255, "row {y}: within the strip's real thickness, the middle column must stay opaque");
+ }
+}
+
+#[test]
+fn context_menu_is_one_row_tall_per_item() {
+ let items = [("Minimize", false), ("Maximize", false), ("Always on Top", false), ("Close", false)];
+ let buf = render_context_menu(160, 28, &items, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x4c, 0x56, 0x6a), (0x10, 0x10, 0x10));
+ assert_eq!(buf.len(), 160 * (28 * 4) * 4);
+}
+
+#[test]
+fn context_menu_highlighted_row_has_a_different_background_than_the_rest() {
+ let items = [("Minimize", false), ("Close", true)];
+ let bg = (0x2e, 0x34, 0x40);
+ let highlight = (0x4c, 0x56, 0x6a);
+ let buf = render_context_menu(160, 28, &items, bg, (0xff, 0xff, 0xff), highlight, (0x10, 0x10, 0x10));
+ let width = 160usize;
+ // Sample a background pixel from each row, away from the text/border.
+ let px_at = |x: usize, y: usize| -> [u8; 3] {
+ let i = (y * width + x) * 4;
+ [buf[i + 2], buf[i + 1], buf[i]] // BGRA -> RGB
+ };
+ assert_eq!(px_at(100, 5), [bg.0, bg.1, bg.2], "row 0 (not highlighted) should use bg");
+ assert_eq!(px_at(100, 33), [highlight.0, highlight.1, highlight.2], "row 1 (highlighted) should use highlight_bg");
+}
+
+#[test]
+fn context_menu_border_is_opaque_at_every_edge() {
+ let items = [("Close", false)];
+ let buf = render_context_menu(100, 28, &items, (0, 0, 0), (0xff, 0xff, 0xff), (0, 0, 0), (0x99, 0x99, 0x99));
+ let alpha_at = |x: usize, y: usize| buf[(y * 100 + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255);
+ assert_eq!(alpha_at(99, 0), 255);
+ assert_eq!(alpha_at(0, 27), 255);
+ assert_eq!(alpha_at(99, 27), 255);
+}
+
+#[test]
+fn snap_flyout_is_sized_for_a_full_grid_of_labels() {
+ let labels = ["Left Half", "Right Half", "Top Left", "Top Right", "Bottom Left", "Bottom Right"];
+ let buf = render_snap_flyout(3, 90, 60, &labels, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x10, 0x10, 0x10));
+ // 3 columns x 2 rows (6 labels / 3 columns, rounded up).
+ assert_eq!(buf.len(), (90 * 3) * (60 * 2) * 4);
+}
+
+#[test]
+fn snap_flyout_border_is_opaque_at_every_outer_edge() {
+ let labels = ["A", "B", "C", "D", "E", "F"];
+ let (cell_w, cell_h) = (90, 60);
+ let buf = render_snap_flyout(3, cell_w, cell_h, &labels, (0, 0, 0), (0xff, 0xff, 0xff), (0x99, 0x99, 0x99));
+ let (width, height) = (cell_w * 3, cell_h * 2);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255);
+ assert_eq!(alpha_at(width as usize - 1, 0), 255);
+ assert_eq!(alpha_at(0, height as usize - 1), 255);
+ assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255);
+}
+
+#[test]
+fn snap_flyout_has_an_internal_grid_line_between_columns() {
+ let labels = ["A", "B", "C", "D", "E", "F"];
+ let (cell_w, cell_h) = (90, 60);
+ let buf = render_snap_flyout(3, cell_w, cell_h, &labels, (0, 0, 0), (0xff, 0xff, 0xff), (0x99, 0x99, 0x99));
+ let width = cell_w * 3;
+ // The boundary between column 0 and column 1, away from the outer border.
+ let idx = (30 * width as usize + cell_w as usize) * 4;
+ assert_eq!(buf[idx + 3], 255, "column boundary must be drawn, not just the outer border");
+}
diff --git a/crates/wayland/src/decoration/titlebar.rs b/crates/wayland/src/decoration/titlebar.rs
new file mode 100644
index 0000000..49f9de5
--- /dev/null
+++ b/crates/wayland/src/decoration/titlebar.rs
@@ -0,0 +1,295 @@
+//! Laying out and rasterizing the whole titlebar band: background, title
+//! text, and the button cluster (which one goes where, which side, how
+//! many). The buttons' own dots/glyphs are `buttons.rs`'s job; the corner
+//! cut at the end is `corners.rs`'s.
+
+use super::buttons::{
+ draw_close_glyph, draw_maximize_glyph, draw_minimize_glyph, draw_zoom_glyph, fill_button_dot, BUTTON_MARGIN, BUTTON_MARGIN_LEFT, TRAFFIC_LIGHT_CLOSE,
+ TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_MAXIMIZE, TRAFFIC_LIGHT_MINIMIZE,
+};
+use super::color::{brighten, darken, rgb_to_bgra};
+use super::corners::round_top_corners;
+use super::font::{blit_glyph, find_system_font, FONT_PIXELS, TEXT_LEFT_PADDING};
+
+/// Renders a `width x height` BGRA8 buffer: filled with `background`, with
+/// `title` drawn left-aligned in `foreground` (best-effort glyph layout --
+/// no text shaping/kerning, adequate for the ASCII-heavy titles window
+/// managers actually display). Returns `None` (caller keeps the previous
+/// solid-color-only look) only if no usable font was found on this system.
+///
+/// `round_corners` should be `false` only for a window whose border strips
+/// are rendered as plain square-cornered fills with no matching rounded
+/// treatment of their own. `border::render_border_top` gives the border's
+/// top strip the same rounded-corner cut (see its own doc comment for how
+/// the two stay visually continuous), so a normal bordered window should
+/// pass `true` here same as a borderless one now - reported live as most
+/// windows (anything with the default border) looking inconsistently
+/// square next to the few borderless ones that were rounded.
+///
+/// `border_width` shifts the corner circle's centre by that many rows (see
+/// `corners::round_top_corners`'s own doc comment): a titlebar with a
+/// border strip sitting above it starts `border_width` rows *into* the
+/// shared circle, not at its top, so it needs the same shift subtracted to
+/// draw its own correct slice of that one circle rather than a second,
+/// uncoordinated one. Pass `0` for an undecorated/borderless window's
+/// titlebar (there is none in practice - an undecorated window has no
+/// titlebar at all - but `0` is also the correct, harmless value if
+/// `round_corners` handling ever changes to allow it).
+#[allow(clippy::too_many_arguments)]
+pub fn render_titlebar(
+ width: u32,
+ height: u32,
+ title: &str,
+ background: (u8, u8, u8),
+ foreground: (u8, u8, u8),
+ round_corners: bool,
+ radius: u32,
+ border_width: u32,
+ focused: bool,
+ // `(button, glyph alpha 0..=255)` - the alpha is the eased hover-
+ // reveal animation's own current progress (see `tick_hover_glyph_
+ // animation`), already discretized by the caller so this stays a
+ // plain data-in function with no `Instant`/timing concept of its own.
+ hovered: Option<(srdwm_core::TitlebarHit, u8)>,
+ centered: bool,
+ buttons_left: bool,
+ // Modern GNOME/Adwaita mode (see `ThemeConfig::button_glyph_always`'s
+ // own doc comment): every glyph drawn at full opacity always, `hovered`
+ // only still used for the background-circle brighten below, not glyph
+ // visibility.
+ glyph_always: bool,
+ // `ThemeConfig::button_order`'s resolved value - see `ButtonOrder`'s
+ // own doc comment. Must stay in agreement with whatever `ResizeEdge::
+ // hit_test` was called with for the same window, the same "renders on
+ // one side, hit-tests on the other" trap `buttons_left` itself already
+ // has to avoid.
+ button_order: Option<srdwm_core::ButtonOrder>,
+ // `ThemeConfig::traffic_light_buttons`'s resolved value - see its own
+ // doc comment for what each mode actually draws differently.
+ traffic_lights: bool,
+ // `Window::is_dialog`'s resolved value - see its own doc comment. Only
+ // Close is ever drawn for a dialog, and never as a coloured traffic
+ // light regardless of `traffic_lights` above (forced off below):
+ // requested directly ("dialog windows shouldn't have maximize/minimize
+ // buttons... don't use traffic lights there ever"). Must stay in exact
+ // agreement with `ResizeEdge::hit_test`'s own `is_dialog` parameter,
+ // the same "renders on one side, hit-tests on the other" trap every
+ // other button-geometry value here already has to avoid.
+ is_dialog: bool,
+) -> Vec<u8> {
+ let (width, height) = (width.max(1) as usize, height.max(1) as usize);
+ // Forced off, not just defaulted - a dialog never gets coloured
+ // traffic lights even when the active theme otherwise uses them
+ // everywhere else.
+ let traffic_lights = traffic_lights && !is_dialog;
+ let bg = rgb_to_bgra(background, 255);
+ let mut buf = vec![0u8; width * height * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+
+ // Reserve the button squares (whichever side they're actually on)
+ // before laying out text, so a long title elides under them the same
+ // way it would under real window furniture rather than drawing on top
+ // of it. `text_start`/`text_limit` bound the span text is allowed to
+ // occupy - both edges when `buttons_left` (buttons eat into the left,
+ // not the right), only the far edge otherwise.
+ let pitch = srdwm_core::BUTTON_PITCH as usize;
+ let cluster_margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ // A dialog only ever gets one button (Close) - see this function's own
+ // `is_dialog` doc comment.
+ let wanted_buttons = if is_dialog { 1 } else { 3 };
+ let button_count = if width >= cluster_margin + pitch * wanted_buttons { wanted_buttons } else { 0 };
+ // `BUTTON_CLUSTER_MARGIN` included, not just the buttons' own `pitch *
+ // button_count` span - the cluster's own leading gap needs reserving
+ // too, or a long title's text could draw underneath it (or, on the
+ // `buttons_left` side, right through the gap between the titlebar's
+ // real edge and the first button).
+ let reserved = if button_count > 0 { cluster_margin + pitch * button_count } else { 0 };
+ let (text_start, text_limit) = if buttons_left { (reserved as f32, width as f32) } else { (TEXT_LEFT_PADDING, width.saturating_sub(reserved) as f32) };
+
+ if let Some(font) = find_system_font() {
+ let baseline = (height as f32 * 0.72).round();
+ // Rasterized up front, not drawn incrementally in one pass - see
+ // `title_centered`'s own doc comment: centering needs the title's
+ // total advance width known *before* the first pixel is placed,
+ // and reusing these glyphs for the real draw below avoids
+ // rasterizing every character twice just to get there. Same
+ // truncation rule as before this existed: a glyph whose own
+ // advance crosses `text_limit` still gets drawn (matches a real
+ // window's furniture starting exactly at `text_limit`, not one
+ // glyph-width short of it), only the *next* one is dropped.
+ let mut glyphs: Vec<(fontdue::Metrics, Vec<u8>)> = Vec::new();
+ let mut total_width = 0.0f32;
+ for ch in title.chars() {
+ if ch.is_control() {
+ continue;
+ }
+ let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
+ let advance = metrics.advance_width;
+ let already_past_limit = text_start + total_width >= text_limit;
+ if already_past_limit {
+ break;
+ }
+ total_width += advance;
+ glyphs.push((metrics, coverage));
+ }
+ // Centered on the *whole* titlebar width, not on the narrower
+ // `text_start..text_limit` span left over after reserving the
+ // button squares - matches real macOS, which ignores its own
+ // traffic-light cluster for centering purposes rather than
+ // centering in the remaining space. Centering in the reserved
+ // span instead (the previous behaviour) put the text visibly off
+ // the window's true center - for a 3-button, 30px-tall titlebar
+ // that's a 90px reservation, shifting the centered point 45px
+ // off true center, exactly the "not real center" a user would
+ // notice at a glance. Still clamped into `text_start..text_limit`
+ // afterward so a long title never draws under the buttons.
+ let start_x = if centered { ((width as f32 - total_width) / 2.0).max(text_start).min((text_limit - total_width).max(text_start)) } else { text_start };
+ let mut pen_x = start_x;
+ for (metrics, coverage) in &glyphs {
+ if metrics.width > 0 && metrics.height > 0 {
+ let glyph_x = pen_x + metrics.xmin as f32;
+ let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32;
+ blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, metrics, coverage, background, foreground);
+ }
+ pen_x += metrics.advance_width;
+ }
+ }
+
+ if button_count > 0 {
+ let (mut close_c, mut minimize_c, mut maximize_c) = if !traffic_lights {
+ // Unused in this mode (no dot is ever filled at rest - see the
+ // `traffic_lights` branch below), except as the base colour
+ // `brighten` starts from for the neutral hover backdrop.
+ (background, background, background)
+ } else if focused {
+ (TRAFFIC_LIGHT_CLOSE, TRAFFIC_LIGHT_MINIMIZE, TRAFFIC_LIGHT_MAXIMIZE)
+ } else {
+ (TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_INACTIVE)
+ };
+ // Explicitly requested background-highlight-on-hover for the
+ // titlebar buttons (see docs/TODO.md) - brightens whichever one
+ // is actually hovered, close included, rather than giving close a
+ // separate hardcoded hover colour: close is already red at rest
+ // (focused) or grey (unfocused), same as the other two, so
+ // "red-on-hover for close" falls out of this same brightening,
+ // not a special case. Brightened as soon as a hover is in
+ // progress at all (any glyph alpha > 0), not gated on it having
+ // finished animating in - the circle brightening and the glyph
+ // reveal read as one combined "waking up" motion when they start
+ // together, not two separately-timed effects.
+ //
+ // One button at a time, not the whole cluster - a group-hover
+ // version (matching real macOS's own behaviour) was tried in this
+ // same session and explicitly reverted: the user confirmed this
+ // project's own convention is per-button, not per-cluster, despite
+ // what real macOS itself does.
+ let (mut close_glyph, mut minimize_glyph, mut maximize_glyph) = (0u8, 0u8, 0u8);
+ match hovered {
+ Some((srdwm_core::TitlebarHit::Close, a)) => {
+ close_c = brighten(close_c);
+ close_glyph = a;
+ }
+ Some((srdwm_core::TitlebarHit::Minimize, a)) => {
+ minimize_c = brighten(minimize_c);
+ minimize_glyph = a;
+ }
+ Some((srdwm_core::TitlebarHit::Maximize, a)) => {
+ maximize_c = brighten(maximize_c);
+ maximize_glyph = a;
+ }
+ _ => {}
+ }
+ // Traditional (non-traffic-light) glyphs are always visible, same
+ // as a real Windows/GNOME titlebar's own icons - there's no
+ // filled dot drawing attention to the button at rest the way a
+ // traffic light does, so hiding the glyph too, pending an explicit
+ // `button_glyph = "always"`, would leave the button showing
+ // nothing at all until hovered.
+ let glyph_always = glyph_always || !traffic_lights;
+ if glyph_always {
+ close_glyph = 255;
+ minimize_glyph = 255;
+ maximize_glyph = 255;
+ }
+ // A traffic-light glyph is `darken`ed from that *same* button's own
+ // (possibly already-`brighten`ed-by-hover) colour - real macOS
+ // draws a dark red mark on the red button, dark amber on the
+ // yellow one, not one shared tint reused across all three (see
+ // `darken`'s own doc comment). A traditional glyph instead uses
+ // the titlebar's actual text colour, drawn straight on the
+ // titlebar's own dark background - a dark-on-dark glyph the
+ // traffic-light shade uses would be unreadable there.
+ let (close_shade, minimize_shade, maximize_shade) =
+ if traffic_lights { (darken(close_c), darken(minimize_c), darken(maximize_c)) } else { (foreground, foreground, foreground) };
+ let margin = if buttons_left { BUTTON_MARGIN_LEFT } else { BUTTON_MARGIN };
+ // Closest-to-the-aligned-edge first - must stay in exact
+ // agreement with `ResizeEdge::hit_test`'s own resolution of the
+ // same two fields, the same "renders on one side, hit-tests on
+ // the other" trap `buttons_left` alone already has to avoid. See
+ // `ButtonOrder`'s own doc comment for why the two built-in
+ // defaults are genuinely different relative orderings, not
+ // mirrors of each other.
+ // A dialog always draws Close, full stop - not just whichever
+ // button a `button_order` override would otherwise put first, or
+ // Minimize/Maximize could still end up the one (and only) button
+ // drawn. `button_count` (1 for a dialog) caps the loop below to
+ // just this first slot either way.
+ let order: srdwm_core::ButtonOrder = if is_dialog {
+ [srdwm_core::TitlebarButton::Close; 3]
+ } else {
+ button_order.unwrap_or(if buttons_left {
+ [srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Minimize, srdwm_core::TitlebarButton::Maximize]
+ } else {
+ [srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Maximize, srdwm_core::TitlebarButton::Minimize]
+ })
+ };
+ // `BUTTON_CLUSTER_MARGIN` first, then each button's own `pitch * i`
+ // spacing after it - must stay in agreement with `ResizeEdge::
+ // hit_test`'s matching `left`/`right` base, the same "renders on
+ // one side, hit-tests on the other" trap every other button-
+ // geometry value here already has to avoid.
+ for (i, button) in order.iter().take(button_count).enumerate() {
+ let offset = srdwm_core::BUTTON_CLUSTER_MARGIN as usize + pitch * i;
+ match button {
+ srdwm_core::TitlebarButton::Close => {
+ // Traditional mode has no dot at rest - only once this
+ // button is actually the hovered one (`close_glyph > 0`,
+ // the same signal the glyph reveal itself already uses)
+ // does the neutral, brightened backdrop appear at all.
+ // A traffic light always fills, rest state included.
+ if traffic_lights || close_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, close_c);
+ }
+ draw_close_glyph(&mut buf, width, height, offset, buttons_left, margin, close_glyph, close_shade);
+ }
+ srdwm_core::TitlebarButton::Minimize => {
+ if traffic_lights || minimize_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, minimize_c);
+ }
+ draw_minimize_glyph(&mut buf, width, height, offset, buttons_left, margin, minimize_glyph, minimize_shade);
+ }
+ srdwm_core::TitlebarButton::Maximize => {
+ if traffic_lights || maximize_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, maximize_c);
+ }
+ // The macOS "zoom" double-arrow only reads correctly
+ // paired with that same convention's traffic lights --
+ // traditional mode keeps the plain square every other
+ // desktop's own maximize icon already uses.
+ if traffic_lights {
+ draw_zoom_glyph(&mut buf, width, height, offset, buttons_left, margin, maximize_glyph, maximize_shade);
+ } else {
+ draw_maximize_glyph(&mut buf, width, height, offset, buttons_left, margin, maximize_glyph, maximize_shade);
+ }
+ }
+ }
+ }
+ }
+ if round_corners {
+ round_top_corners(&mut buf, width, height, radius, radius as i32 - border_width as i32);
+ }
+ buf
+}
+
diff --git a/crates/wayland/src/elements.rs b/crates/wayland/src/elements.rs
index deabd42..d64a02d 100644
--- a/crates/wayland/src/elements.rs
+++ b/crates/wayland/src/elements.rs
@@ -151,42 +151,58 @@ where
/// masked copy of `surface`'s content - see `rounded_corners_pixman`'s
/// module doc comment for what this actually does and why it needs a cache
/// at all. `epoch` is the window's current `CompState::content_epoch`
-/// value (bumped once per real commit, in `commit()`); the cached entry is
-/// only rebuilt when that no longer matches what it was last built from, so
-/// a window that isn't currently repainting costs nothing here beyond one
-/// `HashMap` lookup per frame.
+/// value (bumped once per real commit, in `commit()`); `loc`/`size` are
+/// `rounded_corners_pixman::masked_content_buffer`'s own tree-render
+/// origin and off-screen buffer dimensions (the caller's already-computed
+/// negated `content_offset` and content rect) - the cached entry is
+/// rebuilt whenever any of `epoch`/`radius`/`loc`/`size` no longer match
+/// what it was last built from, so a window that isn't currently
+/// repainting, resizing, or having its shadow-margin geometry renegotiated
+/// costs nothing here beyond one `HashMap` lookup per frame.
///
-/// Free function taking the two fields it needs directly, rather than a
+/// Free function taking the fields it needs directly, rather than a
/// `CompState` method, so it can be called from inside `udev/render.rs`'s
/// loop alongside the already-live `self.udev.as_mut()` borrow - see that
/// call site.
///
-/// `None` either because masking genuinely isn't possible right now (falls
-/// through to `rounded_corners_pixman::masked_content_buffer`'s own `None`
-/// cases) or because it hasn't been attempted yet this call; either way the
-/// caller's fallback is the same: render `surface`'s content unrounded via
-/// [`surface_content_elements`].
+/// `None` either because the off-screen render itself failed (a genuine
+/// renderer error - there is no longer any "this window isn't shaped
+/// right for masking" restriction, see `masked_content_buffer`'s own doc
+/// comment) or because it hasn't been attempted yet this call; either way
+/// the caller's fallback is the same: render `surface`'s content unrounded
+/// via [`surface_content_elements`].
pub(crate) fn rounded_content_buffer<'a>(
- cache: &'a mut std::collections::HashMap<srdwm_core::WindowId, (u64, u32, smithay::backend::renderer::element::memory::MemoryRenderBuffer)>,
+ cache: &'a mut std::collections::HashMap<srdwm_core::WindowId, (u64, u32, (i32, i32), (i32, i32), smithay::backend::renderer::element::memory::MemoryRenderBuffer)>,
+ renderer: &mut smithay::backend::renderer::pixman::PixmanRenderer,
epoch: u64,
id: srdwm_core::WindowId,
surface: &WlSurface,
+ loc: (i32, i32),
+ size: (i32, i32),
radius: f32,
corners: crate::rounded_corners::RoundedCorners,
) -> Option<&'a smithay::backend::renderer::element::memory::MemoryRenderBuffer> {
let radius_bits = radius.to_bits();
- let stale = cache.get(&id).map(|(built, r, _)| *built != epoch || *r != radius_bits).unwrap_or(true);
+ let stale = cache.get(&id).map(|(built, r, l, s, _)| *built != epoch || *r != radius_bits || *l != loc || *s != size).unwrap_or(true);
if stale {
- match crate::rounded_corners_pixman::masked_content_buffer(surface, radius, corners) {
- Some(buf) => {
- cache.insert(id, (epoch, radius_bits, buf));
+ match crate::rounded_corners_pixman::masked_content_buffer(renderer, surface, loc, size, radius, corners) {
+ Some(data) => {
+ let buffer = smithay::backend::renderer::element::memory::MemoryRenderBuffer::from_slice(
+ &data,
+ smithay::backend::allocator::Fourcc::Argb8888,
+ size,
+ 1,
+ smithay::utils::Transform::Normal,
+ None,
+ );
+ cache.insert(id, (epoch, radius_bits, loc, size, buffer));
}
None => {
cache.remove(&id);
}
}
}
- cache.get(&id).map(|(_, _, b)| b)
+ cache.get(&id).map(|(_, _, _, _, b)| b)
}
/// Every mapped layer-shell surface on `output` whose [`Layer`] `include`
@@ -199,7 +215,35 @@ pub(crate) fn rounded_content_buffer<'a>(
/// on `map.layers()` before rendering, so surfaces sharing one `Layer`
/// keep the same relative stacking smithay's convenience wrapper gave
/// them, now that this function replaces it.
-pub(crate) fn output_layer_elements<R>(renderer: &mut R, output: &Output, origin: (i32, i32), include: impl Fn(Layer) -> bool) -> Vec<OverlayElement<R>>
+///
+/// Takes no `origin`/global-position parameter, unlike every *other*
+/// per-head element builder in `udev/render.rs` - deliberately: those all
+/// convert a window's `geometry` (stored in *global*, whole-desktop space)
+/// into this one head's local framebuffer space by subtracting the head's
+/// own `origin`. `LayerMap::layer_geometry` is different - confirmed
+/// against smithay 0.7.0's own source (`desktop/wayland/layer.rs`): its
+/// `zone`/layer positions are built from the output's own mode size alone,
+/// with no global offset baked in at all, so it is *already* head-local.
+/// An earlier version of this function added `origin` to it anyway (to
+/// "match" the other element builders' own pattern without checking
+/// whether the input was actually the same kind of value) - harmless for
+/// a single-output setup or this output's own primary/first head, where
+/// `origin` is always `(0, 0)`, but on a second head at a real nonzero
+/// `origin` (e.g. `(1920, 0)`) it shifted every layer-shell surface - a
+/// wallpaper, a bar - clean off the right edge of that head's own
+/// 1920px-wide local framebuffer, never drawn at all despite the surface
+/// being genuinely mapped, configured, and holding real committed pixel
+/// data the entire time. Reported live as a real second monitor showing
+/// nothing but its own clear colour, confirmed root-caused by adding a
+/// temporary diagnostic (`LAYER-ELEMENTS-DIAG`, since removed) that logged
+/// `layer_count`/`has_buffer` per output - both outputs showed identical,
+/// fully-populated state the whole time, which is what pointed at a
+/// positioning bug downstream of element-gathering rather than anything
+/// about the surfaces or the render/flip pipeline itself (the pipeline
+/// itself was separately confirmed alive on this exact head by moving the
+/// real cursor there and seeing it render correctly, a different code path
+/// with no `layer_geometry` involved at all).
+pub(crate) fn output_layer_elements<R>(renderer: &mut R, output: &Output, include: impl Fn(Layer) -> bool) -> Vec<OverlayElement<R>>
where
R: Renderer + ImportAll + ImportMem,
R::TextureId: Clone + Send + 'static,
@@ -211,8 +255,7 @@ where
continue;
}
let Some(geo) = map.layer_geometry(layer) else { continue };
- let location = (origin.0 + geo.loc.x, origin.1 + geo.loc.y);
- elements.extend(surface_content_elements(renderer, layer.wl_surface(), location, 1.0));
+ elements.extend(surface_content_elements(renderer, layer.wl_surface(), (geo.loc.x, geo.loc.y), 1.0));
}
elements
}
@@ -388,17 +431,43 @@ pub(crate) fn popup_surface_under(state: &CompState, pos: Point<f64, Logical>) -
/// the caller: that case is now handled by a second, always-unconditional
/// pass over the focused/hovered windows specifically, independent of
/// whether this function's damage-gated pass runs at all this tick.
+///
+/// `origin` is the rendering head's own position in the shared global
+/// space - needed because `damage` comes straight from that head's own
+/// `OutputDamageTracker`, which (like every other per-head render element
+/// in `udev/render.rs`) operates entirely in that head's own *local*
+/// framebuffer space, starting at `(0, 0)` regardless of where the head
+/// actually sits in the multi-monitor desktop. `space.element_geometry`,
+/// by contrast, is always in *global* space (`Space` tracks every window
+/// across the whole desktop, not per-output). Comparing the two directly
+/// - what this function used to do - only ever produced a real overlap
+/// for a head whose own `origin` happened to be `(0, 0)`, i.e. the first/
+/// primary monitor in a left-to-right layout; every window on any other
+/// monitor could never be found "touched" by that monitor's own damage at
+/// all, no matter how much of it was actually changing on screen. A
+/// client relying on this path alone - a video window the user had
+/// switched focus *away* from, since the separate always-unconditional
+/// pass above only covers the focused/hovered window - never received
+/// another frame callback once srdwm's own bootstrap-configure frame
+/// callback was used up, and simply stopped rendering new frames forever:
+/// reported live as a paused-looking video on a second monitor, audio
+/// still playing underneath (a completely separate pipeline, unaffected).
+/// Same root cause and same fix shape as `output_layer_elements`'s own
+/// local/global mismatch, found earlier the same session.
pub(crate) fn windows_touched_by_damage<'a>(
space: &'a Space<DWindow>,
damage: &'a [Rectangle<i32, Physical>],
+ origin: Point<i32, Logical>,
scale: Scale<f64>,
) -> impl Iterator<Item = &'a DWindow> + 'a {
+ let origin_phys = origin.to_physical_precise_round(scale);
space.elements().filter(move |w| {
space
.element_geometry(w)
.map(|geo| {
let phys = geo.to_physical_precise_round(scale);
- damage.iter().any(|d| d.overlaps(phys))
+ let local = Rectangle::new(phys.loc - origin_phys, phys.size);
+ damage.iter().any(|d| d.overlaps(local))
})
.unwrap_or(false)
})
diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs
index 5cce9a4..9d31872 100644
--- a/crates/wayland/src/input.rs
+++ b/crates/wayland/src/input.rs
@@ -9,153 +9,46 @@
//! Every function that routes an event checks the session lock first: while
//! locked, input goes to the lock surface and nowhere else. See
//! [`crate::lock`].
+//!
+//! Split by concern, one file per input-event kind, matching niri's own
+//! per-grab-kind module boundaries: [`layers`] (layer-shell hit-testing and
+//! the layer-driven maximize geometry), [`focus`] (focusing/raising/closing
+//! a window - needed by every other kind below regardless of what
+//! triggered the change), [`pointer`] (motion, button, cursor shape),
+//! [`keyboard`] (key events and keysym/modifier translation), [`gestures`]
+//! (workspace scroll and touchpad swipe). `notify_idle_activity` and
+//! [`DRAG_MODIFIER`] stay here, at the root, since every one of those
+//! modules needs at least one of them.
+
+mod focus;
+mod gestures;
+mod keyboard;
+mod layers;
+mod pointer;
-use smithay::backend::input::{ButtonState as BackendButtonState, KeyState as BackendKeyState, KeyboardKeyEvent};
-use smithay::backend::session::Session as _;
-use smithay::desktop::{layer_map_for_output, Window as DWindow, WindowSurfaceType};
-use smithay::input::keyboard::FilterResult;
-use smithay::input::pointer::{ButtonEvent, MotionEvent};
-use smithay::output::Output;
-use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
-use smithay::reexports::wayland_server::Resource as _;
-use smithay::utils::{Logical, Point, SERIAL_COUNTER};
-use smithay::wayland::compositor::with_states;
-use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone, KeyboardInteractivity, Layer, LayerSurfaceCachedState};
use std::time::{Duration, Instant};
-use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId};
+use smithay::utils::{Logical, Point};
+
+use srdwm_core::Modifiers;
+
+use crate::state::CompState;
+
+pub(crate) use focus::{close_dwindow, dwindow_wl_surface, focus_window, raise_in_space, sync_keyboard_focus};
+pub(crate) use gestures::{handle_gesture_swipe_begin, handle_gesture_swipe_end, handle_gesture_swipe_update, handle_workspace_scroll};
+pub(crate) use keyboard::handle_keyboard_key_event;
+pub(crate) use layers::maximize_geometry_for;
+pub(crate) use pointer::{handle_pointer_button, handle_pointer_position};
/// Modifier that turns a drag anywhere in a window into move/resize.
/// Matches the `SUPER` the shipped and ported configs use for
/// `bindm ... movewindow` / `resizewindow`.
-const DRAG_MODIFIER: Modifiers = Modifiers::SUPER;
-
-use crate::state::CompState;
+pub(super) const DRAG_MODIFIER: Modifiers = Modifiers::SUPER;
pub(crate) fn last_pointer_pos(state: &CompState) -> Point<f64, Logical> {
state.seat.get_pointer().map(|p| p.current_location()).unwrap_or_default()
}
-/// Topmost layer-shell surface (if any) under `pos`, checked in the same
-/// above-everything-else stacking order `space_render_elements` renders
-/// `Overlay`/`Top` layers in (bars, launchers, notifications, lock UIs).
-/// `Background`/`Bottom` layers (wallpapers) deliberately aren't checked
-/// here: nothing in scope for the daily-driver gate needs pointer input
-/// routed to them, and space windows should stay clickable over a
-/// wallpaper.
-/// `pos` is in the global space; layer geometry is relative to its own
-/// output, so the pointer is translated into output-local coordinates
-/// before hit-testing and the result translated back out.
-/// Only checked for `Overlay`/`Top` before a window hit-test, and again for
-/// `Bottom`/`Background` after one comes up empty - see the two call
-/// sites in `handle_pointer_button`/`handle_pointer_position` for why it's
-/// split rather than one four-layer loop here. A `Bottom`/`Background`
-/// surface (a desktop-icons layer, a wallpaper daemon that wants clicks) is
-/// meant to sit *behind* normal windows, so a window covering that point
-/// should still get the click; `Overlay`/`Top` (an on-screen keyboard, a
-/// bar, a dock) are meant to sit in front of everything, windows included.
-///
-/// Was `Overlay`/`Top` only, full stop - a `Bottom`-layer surface was
-/// silently unclickable no matter what, since nothing else in
-/// `handle_pointer_button` ever checked layers at all. Not the cause of
-/// the live "clicking the dock does nothing" report (confirmed: that dock
-/// uses `Layer::Top`, which was already checked), but a real, separate gap
-/// found while chasing it - worth closing regardless of whether anything
-/// currently deployed sits at `Bottom`/`Background` yet.
-pub(crate) fn layer_surface_under_layers(state: &CompState, pos: Point<f64, Logical>, layers: [Layer; 2]) -> Option<(WlSurface, Point<i32, Logical>)> {
- let entry = state.output_at(pos)?;
- let origin = entry.location;
- let local = pos - origin.to_f64();
- let map = layer_map_for_output(&entry.output);
- for layer_kind in layers {
- // Not `map.layer_under(layer_kind, local)` - that hands back only
- // the single topmost surface whose *bounding box* contains `local`,
- // and if that one surface's own input region excludes the point
- // (its `surface_under` below returns `None`), the old code gave up
- // on this whole layer-kind rather than trying whatever real,
- // clickable surface is stacked underneath it. A bbox-only pick is
- // exactly wrong the moment two surfaces on the same layer-kind
- // overlap - a transparent, mapped-but-mostly-empty surface (a
- // backdrop-dismiss popup, concretely: `Overview`'s own bbox-wide
- // fallback region was exactly this shape before it was fixed
- // AGS-side) sitting in front of a real one in z-order would
- // silently swallow every click and even every hover/motion event
- // meant for the surface underneath, with no way to reach it at
- // all. Walking every candidate on this layer-kind, topmost first
- // (`.rev()`, matching `layer_under`'s own z-order convention), and
- // falling through to the next when a candidate's real input region
- // doesn't cover the point, is what `layer_under` alone can't do.
- for layer in map.layers_on(layer_kind).rev() {
- let Some(geo) = map.layer_geometry(layer) else { continue };
- if !geo.to_f64().contains(local) {
- continue;
- }
- // Temporary: verifying the `layer_surfaces_shown_once` fix
- // (state/layers.rs) actually stops a reused `wl_surface`'s
- // stale layer-shell entry from outliving its role destroy --
- // live-reproduced this session as a full-monitor click-catcher
- // popup whose hit-tested geometry came back wider than the
- // real output after several open/close cycles. Remove once a
- // restart confirms the geometry stays sane across repeated
- // popup toggles.
- let local_in_surface = local - geo.loc.to_f64();
- // `None` here means "no region ever committed" - per-protocol
- // that means the *whole* surface is input-sensitive, not that
- // nothing is, so it is its own distinct, meaningful answer from
- // `Some([])` (a region was committed and it is empty).
- let region_dump = with_states(layer.wl_surface(), |states| {
- states.cached_state.get::<smithay::wayland::compositor::SurfaceAttributes>().current().input_region.as_ref().map(|r| r.rects.clone())
- });
- log::info!(
- "layer_hit_test: layer={:?} namespace={:?} surface={:?} geo={:?} local_in_surface={:?} input_region={:?}",
- layer_kind,
- layer.namespace(),
- layer.wl_surface().id(),
- geo,
- local_in_surface,
- region_dump
- );
- if let Some((surface, surface_loc)) = layer.surface_under(local - geo.loc.to_f64(), WindowSurfaceType::ALL) {
- return Some((surface, origin + geo.loc + surface_loc));
- }
- }
- }
- None
-}
-
-pub(crate) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
- layer_surface_under_layers(state, pos, [Layer::Overlay, Layer::Top])
-}
-
-/// The `Bottom`/`Background` half of the same lookup - see
-/// `layer_surface_under_layers`'s doc comment for the ordering rationale.
-pub(crate) fn background_layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
- layer_surface_under_layers(state, pos, [Layer::Bottom, Layer::Background])
-}
-
-/// `full` with only a top-anchored layer surface's exclusive zone (a menu
-/// bar) subtracted back out - see `Monitor::maximize_geometry`'s own doc
-/// comment for why maximize needs this third rect, distinct from both
-/// `geometry` (every zone subtracted) and `full_geometry` (none). Shared by
-/// both backends' `monitors()`, same as everything else in this module.
-/// Deliberately re-derived from the layer list rather than reusing
-/// `non_exclusive_zone()`: that smithay helper folds every anchor
-/// together, with no way to ask it to skip a bottom-anchored dock while
-/// still respecting a top-anchored bar.
-pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) -> srdwm_core::Rect {
- let mut rect = full;
- for layer in layer_map_for_output(output).layers() {
- let data = with_states(layer.wl_surface(), |states| *states.cached_state.get::<LayerSurfaceCachedState>().current());
- let ExclusiveZone::Exclusive(amount) = data.exclusive_zone else { continue };
- if data.anchor.contains(Anchor::TOP) && !data.anchor.contains(Anchor::BOTTOM) {
- let shrink = (amount as i32 + data.margin.top).max(0);
- rect.y += shrink;
- rect.height = rect.height.saturating_sub(shrink as u32);
- }
- }
- rect
-}
-
/// `ext_idle_notify_v1`'s whole job is answering "has the user touched an
/// input device recently" - `IdleNotifierState` does the actual timer
/// bookkeeping (see its own doc comment), this just has to be called from
@@ -176,7 +69,7 @@ pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) ->
/// (see `docs/IMPLEMENTATION_STATUS.md`), just cheap enough here (in-memory
/// bookkeeping, not synchronous I/O) that throttling rather than removing
/// it outright is the right amount of caution.
-fn notify_idle_activity(state: &mut CompState) {
+pub(super) fn notify_idle_activity(state: &mut CompState) {
const THROTTLE: Duration = Duration::from_millis(250);
let now = Instant::now();
if state.last_idle_notify.is_some_and(|last| now.duration_since(last) < THROTTLE) {
@@ -186,850 +79,3 @@ fn notify_idle_activity(state: &mut CompState) {
let seat = state.seat.clone();
state.idle_notifier_state.notify_activity(&seat);
}
-
-/// Re-resolves and re-asserts real Wayland pointer focus at `pos` - i.e.
-/// re-runs the exact same layer-shell/decoration/content/background
-/// hit-testing `handle_pointer_position` always did, and calls
-/// `pointer.motion()` with whatever it finds, but *without* sending
-/// `wl_pointer.frame` (callers decide when their own batch of events is
-/// done) and without any of `handle_pointer_position`'s other side effects
-/// (cursor shape, focus-follows-mouse, drag/resize updates) - those only
-/// make sense on an actual motion event, not a button press.
-///
-/// Extracted so [`handle_pointer_button`] can call this immediately before
-/// delivering a click, rather than only ever trusting whatever the *last*
-/// real motion event happened to leave `PointerHandle`'s own focus at.
-/// Those can disagree: confirmed live via a temporary diagnostic (since
-/// removed) that `space.element_under(pos)` - srdwm's own, freshly
-/// computed on every click - and
-/// `PointerHandle::current_focus()` - Wayland's, last set by whichever
-/// motion event happened to run before this click - disagreed on a real
-/// user's real clicks, inconsistently, sometimes on the very same window.
-/// A click landing on stale/no Wayland focus reads exactly like "clicking
-/// doesn't work" or "the cursor isn't where clicking happens," even though
-/// srdwm's own idea of what's under the pointer was correct the whole
-/// time. Calling this right before every button event closes that gap
-/// regardless of why focus went stale, rather than chasing the exact
-/// staleness trigger (rapid clicks, a tap-to-click event with no
-/// intervening motion delta, etc.) one cause at a time.
-#[allow(clippy::type_complexity)]
-fn refresh_pointer_focus(
- state: &mut CompState,
- pos: Point<f64, Logical>,
- time: u32,
-) -> (Option<(WindowId, TitlebarHit)>, bool, bool, Option<WindowId>, Option<(WlSurface, Point<f64, Logical>)>) {
- // Checked before literally everything else, including layer-shell --
- // see `elements::popup_surface_under`'s own doc comment for why: a
- // popup (tooltip, dropdown, right-click menu) always renders on top of
- // everything else, popups on their own parent's content and layer-shell
- // bars/docks alike, and hit-testing has to match that same priority or
- // a click/scroll over an open popup silently lands on whatever's
- // underneath it instead.
- let popup_hit = crate::elements::popup_surface_under(state, pos);
- let layer_hit = layer_surface_under(state, pos);
- // Broadened, not just layer-shell: both a layer surface and an open
- // popup are transient client UI that should suppress WM-level
- // decoration-cursor guessing and focus-follows-mouse the same way (see
- // both call sites below) - hovering a dropdown menu must not refocus
- // whatever window happens to sit underneath it.
- let over_layer_surface = layer_hit.is_some() || popup_hit.is_some();
- let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32);
- let under = state
- .space
- .element_under(pos)
- .filter(|(w, _)| dwindow_is_visible(state, w))
- .map(|(w, loc)| (w.clone(), loc));
- let over_content = under.is_some();
- // Whichever core window the pointer is over right now, decoration or
- // content - `None` while over a layer-shell surface or bare desktop.
- // Only `handle_pointer_position` actually uses this (focus-follows-
- // mouse), but it needs `under` before that's consumed by the match
- // below, so it's computed here rather than recomputed by the caller.
- let hovered_id = hit
- .map(|(id, _)| id)
- .or_else(|| under.as_ref().and_then(|(window, _)| dwindow_wl_surface(window)).and_then(|s| state.surface_to_id.get(&s).copied()));
-
- let Some(pointer) = state.seat.get_pointer() else { return (hit, over_layer_surface, over_content, hovered_id, None) };
- // Freshly resolved target from ordinary hit-testing - overridden below
- // by `pointer_button_grab` when a button is held, per its own doc
- // comment (the Wayland implicit-grab rule).
- let resolved: Option<(WlSurface, Point<f64, Logical>)> = if let Some((surface, loc)) = popup_hit {
- Some((surface, loc.to_f64()))
- } else if let Some((surface, loc)) = layer_hit {
- Some((surface, loc.to_f64()))
- } else if hit.is_some() {
- None // Over our own decoration - no client focus.
- } else if let Some((window, loc)) = &under {
- // `window.toplevel()` is only ever `Some` for a native xdg-shell
- // surface - it's `None` for every XWayland window, and even for a
- // plain xdg-shell one it's always the *root* surface regardless of
- // which subsurface the pointer is actually over (video/GL overlays,
- // some GTK/Electron popups). Either way that meant pointer focus
- // landed on the wrong surface - or no surface at all, for X11
- // clients - and the click coordinates were relative to the window
- // root rather than whatever was actually under the cursor.
- // `Window::surface_under` is smithay's own hit-test for this: it
- // walks the real surface tree (subsurfaces and popups included) and
- // unifies the xdg-shell/X11 cases the way `dwindow_wl_surface` does
- // elsewhere in this module.
- let win_relative = pos - loc.to_f64();
- window.surface_under(win_relative, WindowSurfaceType::ALL).map(|(surface, offset)| (surface, (*loc + offset).to_f64()))
- } else {
- // Bare desktop, no window there either - last chance for a
- // `Bottom`/`Background` layer surface (see
- // `layer_surface_under_layers`'s doc comment) before giving up.
- background_layer_surface_under(state, pos).map(|(surface, loc)| (surface, loc.to_f64()))
- };
- let delivery = state.pointer_button_grab.clone().or_else(|| resolved.clone());
- if let Some((surface, origin)) = delivery {
- let surface_loc = pos - origin;
- pointer.motion(state, Some((surface, origin)), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time });
- } else {
- pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
- }
- (hit, over_layer_surface, over_content, hovered_id, resolved)
-}
-
-pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Logical>, time: u32) {
- notify_idle_activity(state);
- // Locked: pointer motion goes to the lock surface only. No hit-testing
- // against windows/decorations, so no hover, no drag, no resize.
- if state.lock.locked {
- let surface = state.any_lock_surface().cloned();
- if let Some(pointer) = state.seat.get_pointer() {
- let focus = surface.map(|s| (s, Point::from((0, 0)).to_f64()));
- pointer.motion(state, focus, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
- pointer.frame(state);
- }
- return;
- }
-
- let (hit, over_layer_surface, over_content, hovered_id, _) = refresh_pointer_focus(state, pos, time);
- let Some(pointer) = state.seat.get_pointer() else { return };
- // `PointerHandle::motion`/`button`/`axis` only queue the event with the
- // active grab - nothing sends `wl_pointer.frame` on its own (confirmed
- // reading smithay's `DefaultGrab`: its `motion`/`button` impls call
- // straight through to the handle and never call `frame`). `frame` is
- // what tells a client "the events since the last frame are one atomic
- // update, process them now" - required by the protocol since
- // `wl_pointer` version 5, and this compositor advertises v9. Without
- // it, any client that correctly waits for `frame` before acting on
- // motion/button state (most modern toolkits, confirmed live: neither
- // Firefox nor wezterm registered a click or a drag-selection, in both
- // cases with the cursor sitting squarely on the target) never actually
- // processes what it was sent, even though every event up to this point
- // was individually correct. This is likely the real root cause behind
- // this whole session's "clicking/scrolling doesn't work" reports --
- // every fix so far (subsurface routing, decoration geometry, app_id)
- // was real and necessary, but none of them could have mattered if the
- // client was never told to look at what it received.
- pointer.frame(state);
-
- update_cursor_shape(state, hit, over_layer_surface, over_content);
-
- let mut wm = state.wm.borrow_mut();
- let dragging_or_resizing = wm.is_dragging() || wm.is_resizing();
- if wm.is_dragging() {
- wm.update_drag(pos.x as i32, pos.y as i32);
- } else if wm.is_resizing() {
- wm.update_resize(pos.x as i32, pos.y as i32);
- }
- let focused = wm.focused_id();
- // `general.focus_follows_mouse`: hovering a *different* window focuses
- // it, no click needed - classic X11 sloppy focus. Gated on `hit`/
- // `under` actually landing on a window (not a layer surface or bare
- // desktop) and on not already being mid-drag/resize, where the pointer
- // sweeps over unrelated windows constantly and none of that should
- // steal focus from whatever's actually being dragged. `hovered_id !=
- // focused` both skips redundant work on every one of the many motion
- // events a stationary pointer over an already-focused window still
- // generates, and is what makes `auto_raise` (below) only fire on an
- // actual focus change rather than every motion tick too.
- let focus_follow_target =
- (wm.focus_follows_mouse && !dragging_or_resizing && !over_layer_surface).then_some(hovered_id).flatten().filter(|id| Some(*id) != focused);
- if let Some(id) = focus_follow_target {
- if wm.auto_raise {
- // `raise_window` alone here, not `focus_window` - the actual
- // core + real Wayland/X11 keyboard focus change happens once,
- // below, through the same `focus_window` free function every
- // click-driven focus change already goes through (sets real
- // keyboard focus too, which `WindowManager::focus_window`
- // alone does not).
- wm.raise_window(id);
- }
- }
- drop(wm);
- if let Some(id) = focus_follow_target {
- focus_window(state, id);
- }
- if dragging_or_resizing {
- if let Some(id) = focused {
- state.sync_geometry(id);
- }
- }
-}
-
-/// Sets the pointer to a resize-direction shape while hovering (or
-/// actively dragging) one of our own decoration's resize edges, and back
-/// to the default arrow when leaving our decoration for anything else.
-///
-/// Only ever touches `cursor_status` for our own decoration - never while
-/// `layer_hit`/client content has focus, since a client surface drives its
-/// own cursor via `wl_pointer.set_cursor` once it starts receiving
-/// `pointer.motion()`/`enter` (already sent above, by the time this runs),
-/// and stomping on that here would fight the client for control of its own
-/// cursor rather than just leaving it alone.
-///
-/// Without this, `cursor_status` was only ever set by client requests --
-/// nothing on the compositor's own side ever asked for a resize cursor at
-/// all, so hovering or dragging one of our own decoration's edges never
-/// looked any different from hovering plain content, regardless of what
-/// shapes `cursor.rs` can actually render.
-///
-/// `over_content` distinguishes "over a client surface that will drive its
-/// own cursor" from "over the bare desktop, where nothing ever will" --
-/// without it, dragging off one of our decoration's resize edges straight
-/// onto empty desktop left `cursor_status` stuck on that resize icon
-/// forever: there is no client there to ever call `set_cursor` and reset
-/// it, and this function's own early-return (for the "let the client drive
-/// it" case) doesn't distinguish an *absent* client from a slow one.
-fn update_cursor_shape(state: &mut CompState, hit: Option<(WindowId, TitlebarHit)>, over_layer_surface: bool, over_content: bool) {
- use smithay::input::pointer::{CursorIcon, CursorImageStatus};
-
- if over_layer_surface {
- return;
- }
- let edge = match hit {
- Some((_, TitlebarHit::Resize(edge))) => Some(edge),
- _ => state.wm.borrow().resize_edge(),
- };
- let icon = match edge {
- Some(edge) => resize_cursor_icon(edge),
- // Hovering our own decoration but not an edge (the drag area, a
- // button) and not actively resizing: back to the plain arrow.
- None if hit.is_some() => CursorIcon::Default,
- // Over a client's own content: leave `cursor_status` alone, per the
- // doc comment above - the client drives it.
- None if over_content => return,
- // Bare desktop: nothing else will ever reset this, so we have to.
- None => CursorIcon::Default,
- };
- state.cursor_status = CursorImageStatus::Named(icon);
-}
-
-fn resize_cursor_icon(edge: srdwm_core::ResizeEdge) -> smithay::input::pointer::CursorIcon {
- use smithay::input::pointer::CursorIcon;
- use srdwm_core::ResizeEdge;
- match edge {
- ResizeEdge::Left | ResizeEdge::Right => CursorIcon::EwResize,
- ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::NsResize,
- ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize,
- ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize,
- }
-}
-
-/// The underlying `wl_surface` for a mapped window, regardless of whether
-/// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` --
-/// `desktop::Window` exposes these as two separate accessors with no
-/// shared one.
-pub(crate) fn dwindow_wl_surface(w: &DWindow) -> Option<WlSurface> {
- if let Some(top) = w.toplevel() {
- return Some(top.wl_surface().clone());
- }
- w.x11_surface().and_then(|x| x.wl_surface())
-}
-
-/// Whether `w` is actually visible right now - on the current workspace and
-/// not minimized - matching `WindowManager::visible_windows`'s own filter.
-///
-/// `state.space` (smithay's `Space`) is not workspace-aware: a window stays
-/// mapped in it, and so stays hit-testable by `Space::element_under`, from
-/// the moment it's created until it's explicitly minimized or destroyed --
-/// switching workspace never unmaps anything (see `minimize` in
-/// `udev::platform`, the only other place that calls `unmap_elem`, and the
-/// absence of any workspace-switch handler that touches `self.space` at
-/// all). Without this check, `element_under` freely returns a window sitting
-/// on a workspace that isn't even shown, and a click "through" empty desktop
-/// on the current workspace silently focuses/raises/moves motion onto that
-/// invisible window instead of whatever (if anything) is really there.
-fn dwindow_is_visible(state: &CompState, w: &DWindow) -> bool {
- let Some(id) = dwindow_wl_surface(w).and_then(|s| state.surface_to_id.get(&s).copied()) else { return false };
- let wm = state.wm.borrow();
- wm.window(id).is_some_and(|win| !win.minimized && win.workspace == wm.current_workspace())
-}
-
-/// Requests a client close its window, whichever kind it is.
-pub(crate) fn close_dwindow(w: &DWindow) {
- if let Some(top) = w.toplevel() {
- top.send_close();
- } else if let Some(x11) = w.x11_surface() {
- let _ = x11.close();
- }
-}
-
-/// Focuses `id` in our own `WindowManager` *and* gives its surface real
-/// Wayland/X11 keyboard focus - without this, a window can be raised and
-/// tiled correctly yet never receive a single keystroke.
-pub(crate) fn focus_window(state: &mut CompState, id: WindowId) {
- state.wm.borrow_mut().focus_window(id);
- // Raises the window in smithay's own `Space` too, not just core's
- // `order` - `Space` keeps a completely independent stacking order of
- // its own, which is what actually renders on top *and* what
- // `space.element_under` hit-tests against; `WindowManager::order`
- // (which `focus_window` above already updates) has no effect on
- // either. Without this, any focus path that doesn't also happen to
- // raise `Space` manually (Alt-Tab, a dock's IPC "focus" dispatch,
- // scratchpad show, the Snap-Layouts flyout, ...) left a window
- // genuinely focused - keyboard input, core's own idea of "topmost"
- // both correct - while it kept rendering *underneath* whatever was
- // already on top, and a click on the visible (stale-topmost) window
- // silently reached that one instead. "Focus doesn't bring a window to
- // the front" and "clicking through a window that's fully covering
- // another" are the same root cause, not two bugs. Previously only the
- // plain-content-click branch in `handle_pointer_button` did this,
- // manually, immediately before calling this function - every other
- // caller went through unraised. Cheap even when the window is already
- // topmost (`raise_element` on an already-last element is a no-op
- // reinsertion), so unconditional here rather than gated on whether
- // focus is actually changing.
- if let Some(w) = state.id_to_window.get(&id).cloned() {
- state.space.raise_element(&w, true);
- state.raise_pinned();
- }
- state.pending.borrow_mut().push(CoreEvent::WindowFocused(id));
- let surface = state.id_to_window.get(&id).and_then(dwindow_wl_surface);
- // Routed through `set_keyboard_focus` (rather than calling
- // `KeyboardHandle::set_focus` directly) so clipboard/primary-selection
- // focus follows window focus too - see that method's doc comment.
- state.set_keyboard_focus(surface);
-}
-
-/// Re-syncs real Wayland/X11 keyboard focus to whatever `WindowManager`
-/// already considers focused, without changing what that is.
-///
-/// For callers where core's own focus already moved on its own --
-/// specifically `WindowManager::remove_window`'s fallback to
-/// `self.order.last()` when the just-closed window was the focused one --
-/// and only the Wayland/X11 side needs to catch up to it. Without this, the
-/// window core now considers focused (and renders as such) never actually
-/// receives a keystroke until it's clicked, since nothing told
-/// `set_keyboard_focus` focus had moved.
-///
-/// `focus_window` above is for the opposite direction: driving core's
-/// focus deliberately (a click, a keybinding) and syncing outward from
-/// that. This is "core already decided, catch the rest of the compositor
-/// up" - `wm.focus_window` must not be called again here, since the id
-/// core picked (or `None`, if nothing is left) is exactly what should win.
-pub(crate) fn sync_keyboard_focus(state: &mut CompState) {
- let focused = state.wm.borrow().focused_id();
- let surface = focused.and_then(|id| state.id_to_window.get(&id)).and_then(dwindow_wl_surface);
- state.set_keyboard_focus(surface);
-}
-
-pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logical>, button: u32, pressed: bool, time: u32) {
- notify_idle_activity(state);
- const BTN_LEFT: u32 = 0x110;
- const BTN_RIGHT: u32 = 0x111;
- const BTN_MIDDLE: u32 = 0x112;
- let serial = SERIAL_COUNTER.next_serial();
-
- // Locked: forward the click to the lock surface (it may have a button or
- // a text field) but never let it focus, raise, drag, or close a window.
- if state.lock.locked {
- if let Some(pointer) = state.seat.get_pointer() {
- let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
- pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
- pointer.frame(state);
- }
- return;
- }
-
- // The context menu, if open, captures every press: a click inside
- // resolves whichever row it landed on, a click anywhere else just
- // dismisses it. Neither case falls through to the normal handling
- // below - opening the menu and then clicking a window underneath it
- // should not *also* focus/raise/drag that window on the same click,
- // the same "one click, one action" rule every native window menu
- // follows.
- if pressed {
- if let Some(menu) = state.context_menu.take() {
- if let Some(row) = menu.row_at(pos.x as i32, pos.y as i32) {
- let (_, action) = menu.items[row];
- state.close_context_menu();
- state.run_context_menu_action(menu.window, action);
- } else {
- state.close_context_menu();
- }
- return;
- }
- // Same "one click, one action" rule as the context menu above --
- // a click inside the Snap-Layouts flyout applies that zone, a click
- // anywhere else just dismisses it.
- if let Some(flyout) = state.snap_flyout.take() {
- if let Some(zone) = flyout.zone_at(pos.x as i32, pos.y as i32) {
- state.close_snap_flyout();
- state.run_snap_flyout_action(flyout.window, zone);
- } else {
- state.close_snap_flyout();
- }
- return;
- }
- }
-
- // Modifier+drag: with the modifier held, dragging *anywhere* in a window
- // moves it (left button) or resizes it from the nearest corner (right
- // button) - the `bindm SUPER, mouse:272/273` gesture. Without this a
- // window can only be moved by its titlebar, which is useless for
- // windows that have none (fullscreen, CSD apps, layer surfaces).
- //
- // Checked before the titlebar hit-test so the modifier wins over the
- // decoration: holding the modifier and grabbing the titlebar should
- // still move, not press a titlebar button.
- if pressed && (button == BTN_LEFT || button == BTN_RIGHT) {
- let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state()));
- if mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) {
- let target = state.wm.borrow().window_at(pos.x as i32, pos.y as i32);
- if let Some(id) = target {
- focus_window(state, id);
- let mut wm = state.wm.borrow_mut();
- if button == BTN_LEFT {
- wm.start_drag(id, pos.x as i32, pos.y as i32);
- } else {
- let edge = wm.nearest_corner(id, pos.x as i32, pos.y as i32);
- wm.start_resize(id, edge, pos.x as i32, pos.y as i32);
- }
- return;
- }
- }
- }
-
- if pressed && button == BTN_LEFT {
- let layer_hit = layer_surface_under(state, pos);
- if let Some((surface, _)) = &layer_hit {
- // Look the surface up on whichever output actually holds it.
- let on_demand = state
- .outputs()
- .find_map(|output| {
- layer_map_for_output(output)
- .layer_for_surface(surface, WindowSurfaceType::ALL)
- .map(|l| {
- l.can_receive_keyboard_focus()
- && l.cached_state().keyboard_interactivity != KeyboardInteractivity::Exclusive
- })
- })
- .unwrap_or(false);
- // `Exclusive` layers (lock screens, exclusive launchers) already
- // hold focus from `ensure_layer_initial_configure` and keep it
- // regardless of where else is clicked; only `OnDemand` layers
- // (e.g. a bar's search field) claim it on click.
- if on_demand {
- state.set_keyboard_focus(Some(surface.clone()));
- }
- }
- let hit = if layer_hit.is_some() { None } else { state.wm.borrow().hit_test(pos.x as i32, pos.y as i32) };
- if let Some((id, hit)) = hit {
- focus_window(state, id);
- match hit {
- TitlebarHit::Drag => {
- // Double-click the titlebar to maximise, as every other
- // desktop does - one of the few window operations that
- // otherwise needs the keyboard or a precise button hit.
- if state.is_double_click(id, time) {
- state.wm.borrow_mut().toggle_maximize(id);
- state.sync_geometry(id);
- crate::foreign_toplevel::send_state(state, id);
- } else {
- state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32)
- }
- }
- TitlebarHit::Close => {
- if let Some(w) = state.id_to_window.get(&id) {
- close_dwindow(w);
- }
- }
- TitlebarHit::Maximize => {
- state.wm.borrow_mut().toggle_maximize(id);
- state.sync_geometry(id);
- crate::foreign_toplevel::send_state(state, id);
- }
- TitlebarHit::Minimize => {
- state.wm.borrow_mut().minimize_window(id);
- crate::foreign_toplevel::send_state(state, id);
- }
- TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32),
- }
- } else if layer_hit.is_none() {
- if let Some((window, _loc)) = state.space.element_under(pos).filter(|(w, _)| dwindow_is_visible(state, w)) {
- let window = window.clone();
- // `focus_window` itself raises both `Space` and pinned
- // windows now - see its own doc comment. No longer done
- // manually here first.
- if let Some(&id) = dwindow_wl_surface(&window).and_then(|s| state.surface_to_id.get(&s)) {
- focus_window(state, id);
- }
- }
- }
- } else if pressed && (button == BTN_RIGHT || button == BTN_MIDDLE) {
- // Right-click a titlebar: open the window menu (minimize/maximize/
- // pin/close) - previously nothing at all, since the only
- // right-button behaviour anywhere was the SUPER+right-drag resize
- // gesture above, which needs the modifier held. Middle-click:
- // lower the window instead, the convention several X11 WMs
- // (twm, fvwm, IceWM) have always had. Both only fire on the
- // titlebar's plain drag area - a resize edge or one of the three
- // buttons keeps its own single meaning regardless of which button
- // was pressed, so a right-click on the close button, say, doesn't
- // do something else entirely.
- let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32);
- match (button, hit) {
- (BTN_RIGHT, Some((id, TitlebarHit::Drag))) => state.open_context_menu(id, (pos.x as i32, pos.y as i32)),
- (BTN_MIDDLE, Some((id, TitlebarHit::Drag))) => state.wm.borrow_mut().lower_window(id),
- // Right-click the maximize button itself: the Snap-Layouts
- // flyout (pick a half/quarter position for this window)
- // instead of the window menu - a plain left-click there still
- // just toggles maximize, unchanged.
- (BTN_RIGHT, Some((id, TitlebarHit::Maximize))) => state.open_snap_flyout(id, (pos.x as i32, pos.y as i32)),
- _ => {}
- }
- } else if !pressed {
- let mut wm = state.wm.borrow_mut();
- let was_dragging = wm.is_dragging();
- let was_resizing = wm.is_resizing();
- // `start_drag`/`start_resize` both focus the window they grab, and
- // nothing else can change focus while a grab is active (the pointer
- // is captured by the drag, not routed elsewhere) - so `focused_id`
- // is reliably the window `end_drag`/`end_resize` are about to
- // finish, without `WindowManager` needing to hand the id back
- // itself.
- let id = wm.focused_id();
- if was_dragging {
- wm.end_drag();
- } else if was_resizing {
- wm.end_resize();
- }
- drop(wm);
- // `end_drag` can snap the geometry one more time (edge/top-of-
- // screen snapping, `SmartPlacement::snap_zone`) *after* the last
- // `update_drag` already moved the window - without this, that
- // final snap only ever reached `Window.geometry`. The border and
- // titlebar redraw fresh from live geometry every frame, so they'd
- // jump to the snapped rect immediately, while the client's actual
- // mapped surface (driven only by `sync_geometry`'s
- // `space.map_element`/`xdg_toplevel.configure`) stayed wherever the
- // drag physically stopped - decoration visibly detached from its
- // own window's content. Click routing desynced the same way:
- // `hit_test`/`window_at` read the now-snapped `Window.geometry`
- // while `space.element_under` still read the stale pre-snap
- // position, so clicks in the visually-snapped zone resolved
- // against the wrong rect. The X11 backend already gets this right
- // (`crates/x11/src/lib.rs`'s `ButtonRelease` handler); this was the
- // one call site in the module doc'd as "shared by both backends"
- // that never got the same fix.
- if was_dragging || was_resizing {
- if let Some(id) = id {
- state.sync_geometry(id);
- }
- }
- }
-
- // Re-assert real Wayland pointer focus at `pos` immediately before the
- // actual click - see `refresh_pointer_focus`'s own doc comment for why
- // this can't just trust whatever the last motion event left focus at.
- // A no-op from the client's perspective when focus was already correct
- // (an idempotent motion event at the same surface-local coordinates it
- // already has), so this costs nothing in the common case.
- //
- // Also where `pointer_button_grab` starts and ends - see its own doc
- // comment. Only the 0->1 transition captures a new grab target (a
- // second button going down mid-gesture keeps whatever the first press
- // already locked in); only the ->0 transition releases it, and not
- // before this press/release's own `pointer.button()` below still goes
- // out under the (still-active) grab.
- let (.., resolved) = refresh_pointer_focus(state, pos, time);
- if pressed {
- if state.pointer_buttons_held == 0 {
- state.pointer_button_grab = resolved;
- }
- state.pointer_buttons_held += 1;
- } else {
- state.pointer_buttons_held = state.pointer_buttons_held.saturating_sub(1);
- }
- if let Some(pointer) = state.seat.get_pointer() {
- let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
- pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
- // See the matching comment in `handle_pointer_position`: `button`
- // alone never tells the client the event is ready to act on, only
- // `frame` does.
- pointer.frame(state);
- }
- if !pressed && state.pointer_buttons_held == 0 {
- state.pointer_button_grab = None;
- }
-}
-
-/// Shared between the winit (nested) and udev (bare-TTY) backends: both
-/// deliver keyboard events through smithay's generic `KeyboardKeyEvent`
-/// trait, so the precise-keybinding-matching logic (see the module docs)
-/// only needs to exist once.
-pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend, E: KeyboardKeyEvent<B>>(state: &mut CompState, event: &E) {
- notify_idle_activity(state);
- let keycode = event.key_code();
- let key_state = event.state();
- let time = event.time_msec();
- let serial = SERIAL_COUNTER.next_serial();
- let Some(keyboard) = state.seat.get_keyboard() else { return };
-
- // While the session is locked, every key goes to the lock surface and
- // *nothing* is treated as a WM keybinding. Skipping this would leave the
- // lock trivially bypassable - the config binds spawn commands
- // (`Mod4+Return` opens a terminal), so honouring bindings here would let
- // anyone at a locked screen run arbitrary programs.
- if state.lock.locked {
- // A native lock (`crate::native_lock`) has no external client
- // surface to forward to at all - srdwm is its own locker, so
- // every keystroke feeds the password buffer directly instead.
- // Only on press: a character is typed on key-down, matching
- // ordinary text input, and password/BackSpace/Return/Escape
- // handling only make sense once per physical keystroke, not once
- // per press *and* release.
- if state.lock.native.is_some() {
- if key_state == BackendKeyState::Pressed {
- keyboard.input::<(), _>(state, keycode, key_state, serial, time, |data, mods, handle| {
- // `keysym_to_utf8` on the already-resolved keysym
- // (rather than the state-aware `xkb_state_key_get_
- // utf8` xkbcommon's own docs recommend) is a
- // deliberate simplification: correct for plain
- // ASCII/shifted-symbol passwords, which is the
- // overwhelming common case; the gap is dead-key/
- // compose sequences spanning more than one keypress,
- // which would just make that one character not match
- // rather than ever falsely succeed - a usability
- // rough edge, not a security one. Computed before
- // `keysym_name_for` below, which takes `handle` by
- // value.
- let utf8 = xkbcommon::xkb::keysym_to_utf8(handle.modified_sym());
- let name = keysym_name_for(handle).unwrap_or_default();
- data.native_lock_key(&name, &utf8, mods.caps_lock);
- FilterResult::Intercept(())
- });
- } else {
- keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Intercept(()));
- }
- return;
- }
- keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Forward);
- return;
- }
-
- let bound_keys = state.bound_keys.clone();
- let matched: Option<(String, Modifiers)> =
- keyboard.input(state, keycode, key_state, serial, time, move |data, mods, handle| {
- let modifiers = core_modifiers_from_xkb(mods);
- // `Ctrl+Alt+F1`..`F12` (xkb emits these as the `XF86Switch_VT_1`..
- // `_12` keysyms, not a plain function-key + modifier combo) --
- // handled here, by raw keysym *value* rather than name, since
- // matching a name string wrong fails silently and looks
- // identical to this never having been implemented at all (it
- // wasn't, until now: reported live, the user had to leave the
- // graphical session entirely and log in on a different TTY to
- // get a shell back after srdwm went down, because nothing ever
- // told the session to switch away). Values are contiguous
- // (0x1008FE01..=0x1008FE0C, xkbcommon's `keysyms.rs`), so `raw -
- // KEY_XF86SWITCH_VT_1 + 1` is the target VT. Udev/bare-TTY
- // backend only - `data.udev` is `None` under the nested winit
- // backend, where VT switching is meaningless, so this is a
- // no-op there rather than an error, same as every other
- // udev-only feature in this module.
- const KEY_XF86SWITCH_VT_1: u32 = 0x1008_FE01;
- const KEY_XF86SWITCH_VT_12: u32 = 0x1008_FE0C;
- let raw = handle.modified_sym().raw();
- if (KEY_XF86SWITCH_VT_1..=KEY_XF86SWITCH_VT_12).contains(&raw) {
- if key_state == BackendKeyState::Pressed {
- if let Some(udev) = data.udev.as_mut() {
- let vt = (raw - KEY_XF86SWITCH_VT_1 + 1) as i32;
- if let Err(e) = udev.session.change_vt(vt) {
- log::warn!("udev: change_vt({vt}) failed: {e}");
- }
- }
- }
- return FilterResult::Intercept((String::new(), modifiers));
- }
- match keysym_name_for(handle) {
- Some(name) if bound_keys.contains(&srdwm_core::key_combo_string(modifiers, &name)) => {
- FilterResult::Intercept((name, modifiers))
- }
- _ => FilterResult::Forward,
- }
- });
-
- match key_state {
- BackendKeyState::Pressed => {
- // An empty `key_name` is the VT-switch case above, already
- // fully handled inside the closure - it isn't a real
- // keybinding and must not start a repeat timer or fire a
- // `CoreEvent::KeyPress` (`Lua` config has nothing bound to `""`,
- // so this would be harmless either way, but skipping it is both
- // cheaper and clearer than relying on that).
- if let Some((key_name, modifiers)) = matched {
- if !key_name.is_empty() {
- state.begin_repeat(keycode, &key_name, modifiers);
- state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers });
- }
- }
- }
- // Any release ends a repeat of *that* key; releasing an unrelated
- // key must not stop it.
- BackendKeyState::Released => state.end_repeat(keycode),
- }
- // Unmatched keys were already forwarded to the focused client by
- // `FilterResult::Forward` inside the closure above.
-}
-
-/// Translates the effective xkb keysym for this keypress into the same
-/// `"Return"`/`"a"`/`"F5"`-style name `srdwm_core::keysyms` uses, so a
-/// binding written once in Lua resolves identically on X11 and Wayland.
-pub(crate) fn keysym_name_for(handle: smithay::input::keyboard::KeysymHandle<'_>) -> Option<String> {
- srdwm_core::keysyms::keysym_to_name(handle.modified_sym().raw())
-}
-
-pub(crate) fn core_modifiers_from_xkb(mods: &smithay::input::keyboard::ModifiersState) -> Modifiers {
- let mut m = Modifiers::empty();
- if mods.shift {
- m |= Modifiers::SHIFT;
- }
- if mods.ctrl {
- m |= Modifiers::CTRL;
- }
- if mods.alt {
- m |= Modifiers::ALT;
- }
- if mods.logo {
- m |= Modifiers::SUPER;
- }
- m
-}
-
-/// Modifier+scroll cycles workspaces, consuming the event.
-///
-/// Returns `true` if it handled the scroll, in which case the caller must
-/// *not* also forward it to the client. Generic over the input backend for
-/// the same reason the keyboard handler is: both backends deliver scroll
-/// through smithay's `PointerAxisEvent` trait.
-pub(crate) fn handle_workspace_scroll<B, E>(state: &mut CompState, event: &E) -> bool
-where
- B: smithay::backend::input::InputBackend,
- E: smithay::backend::input::PointerAxisEvent<B>,
-{
- use smithay::backend::input::Axis;
-
- notify_idle_activity(state);
- if state.lock.locked {
- return false;
- }
- let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state()));
- if !mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) {
- return false;
- }
- let Some(v) = event.amount(Axis::Vertical).filter(|v| *v != 0.0) else { return false };
- // Scrolling down (positive) advances, matching `workspace, e+1`.
- switch_workspace_relative(state, v > 0.0)
-}
-
-/// Switches to the next (`forward`) or previous workspace in id order,
-/// wrapping around, and fires the two follow-up broadcasts a plain
-/// `WindowManager::switch_workspace` call alone doesn't cover. The shared
-/// body behind every *relative* workspace switch - `SUPER`+scroll above,
-/// and a 3+-finger touchpad swipe (`handle_gesture_swipe_end` below) --
-/// pulled out here rather than duplicated a second time: both gaps below
-/// were found missing for the scroll gesture specifically during this same
-/// session, and nothing about either is scroll-only, so a second call site
-/// copy-pasting the same steps would have been one missed broadcast away
-/// from reintroducing the exact bug that was just fixed once already.
-/// Returns `false` (and does nothing) if there are no workspaces at all.
-fn switch_workspace_relative(state: &mut CompState, forward: bool) -> bool {
- let mut wm = state.wm.borrow_mut();
- let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect();
- if ids.is_empty() {
- return false;
- }
- let current = ids.iter().position(|&id| id == wm.current_workspace()).unwrap_or(0);
- let next = if forward { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() };
- wm.switch_workspace(ids[next]);
- drop(wm);
- // Without this, the switch above is invisible: nothing shows or hides
- // a single window for the new workspace until `main.rs`'s `sync()`
- // runs, which only happens when a polled event sets `dirty` - see
- // `srdwm_core::Event::WorkspaceChanged`'s doc comment. Found live-
- // testing the unrelated `ext_workspace_v1` protocol's own `activate`
- // request, which has the identical problem; the scroll gesture had the
- // exact same bug already, just never one anyone traced back this far.
- state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged);
- // Same reasoning as `foreign_toplevel::send_state`'s call sites: without
- // this, a dock's workspace pill only ever tracked switches driven
- // through `ext_workspace_handle_v1.activate` itself, going stale the
- // moment a gesture (or any other non-protocol trigger) changed the
- // active workspace instead.
- crate::workspace::broadcast_active_workspace(state);
- true
-}
-
-/// A 3+-finger touchpad swipe just started - resets the running horizontal
-/// offset `handle_gesture_swipe_update` accumulates into, or leaves it
-/// `None` while the session is locked so a swipe over the lock screen does
-/// nothing (matching every other pointer/keyboard path's "locked: no normal
-/// handling" rule - see this module's own doc comment).
-pub(crate) fn handle_gesture_swipe_begin<B, E>(state: &mut CompState, event: &E)
-where
- B: smithay::backend::input::InputBackend,
- E: smithay::backend::input::GestureBeginEvent<B>,
-{
- notify_idle_activity(state);
- state.gesture_swipe = if state.lock.locked { None } else { Some((event.fingers(), 0.0)) };
-}
-
-/// Accumulates one update's worth of horizontal motion into the swipe
-/// started by `handle_gesture_swipe_begin` - `delta_x` is relative to the
-/// *previous* update, not a running total (see `gesture_swipe`'s own doc
-/// comment on `CompState`), so summing here is the only way to know the
-/// swipe's real total distance once it ends.
-pub(crate) fn handle_gesture_swipe_update<B, E>(state: &mut CompState, event: &E)
-where
- B: smithay::backend::input::InputBackend,
- E: smithay::backend::input::GestureSwipeUpdateEvent<B>,
-{
- if let Some((_, total_dx)) = state.gesture_swipe.as_mut() {
- *total_dx += event.delta_x();
- }
-}
-
-/// A touchpad swipe just ended - switches workspace if it was a genuine
-/// 3+-finger swipe past `SWIPE_THRESHOLD` and wasn't cancelled (a libinput
-/// gesture is marked cancelled when it doesn't resolve to a clean single
-/// direction, e.g. the fingers moved back and forth). Below the threshold
-/// or below 3 fingers, this does nothing - the same "did you mean it"
-/// floor a mis-clicked drag gets elsewhere in this file, and 2-finger
-/// motion is already handled as ordinary scroll (`PointerAxis`) rather
-/// than reaching here at all on a correctly configured touchpad.
-///
-/// Deliberately claimed entirely by the compositor rather than forwarded to
-/// the focused client, unlike pinch/hold (forwarded as-is in
-/// `udev::session`): `wp_pointer_gestures` swipe is specifically the
-/// 3/4-finger overview-style gesture, and the handful of desktops that
-/// support it at all (GNOME, sway, Hyprland) all reserve it for workspace
-/// switching the same way - there is no real client-side consumer to lose
-/// by not forwarding it. Swipe left (negative `total_dx`) advances to the
-/// next workspace, right goes back, matching macOS's own convention for
-/// swiping between spaces.
-const SWIPE_THRESHOLD: f64 = 60.0;
-
-pub(crate) fn handle_gesture_swipe_end<B, E>(state: &mut CompState, event: &E)
-where
- B: smithay::backend::input::InputBackend,
- E: smithay::backend::input::GestureEndEvent<B>,
-{
- let Some((fingers, total_dx)) = state.gesture_swipe.take() else { return };
- if event.cancelled() || fingers < 3 || total_dx.abs() < SWIPE_THRESHOLD {
- return;
- }
- switch_workspace_relative(state, total_dx < 0.0);
-}
diff --git a/crates/wayland/src/input/focus.rs b/crates/wayland/src/input/focus.rs
new file mode 100644
index 0000000..ade0759
--- /dev/null
+++ b/crates/wayland/src/input/focus.rs
@@ -0,0 +1,138 @@
+//! Focusing, raising, and closing a window - the small set of helpers
+//! every other input-handling module needs regardless of what triggered
+//! the focus change (a click, a keybinding, an IPC call, a closed window's
+//! fallback).
+
+use smithay::desktop::Window as DWindow;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+
+use srdwm_core::{Event as CoreEvent, WindowId};
+
+use crate::state::CompState;
+
+/// The underlying `wl_surface` for a mapped window, regardless of whether
+/// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` --
+/// `desktop::Window` exposes these as two separate accessors with no
+/// shared one.
+pub(crate) fn dwindow_wl_surface(w: &DWindow) -> Option<WlSurface> {
+ if let Some(top) = w.toplevel() {
+ return Some(top.wl_surface().clone());
+ }
+ w.x11_surface().and_then(|x| x.wl_surface())
+}
+
+/// Whether `w` is actually visible right now - on the current workspace and
+/// not minimized - matching `WindowManager::visible_windows`'s own filter.
+///
+/// `state.space` (smithay's `Space`) is not workspace-aware: a window stays
+/// mapped in it, and so stays hit-testable by `Space::element_under`, from
+/// the moment it's created until it's explicitly minimized or destroyed --
+/// switching workspace never unmaps anything (see `minimize` in
+/// `udev::platform`, the only other place that calls `unmap_elem`, and the
+/// absence of any workspace-switch handler that touches `self.space` at
+/// all). Without this check, `element_under` freely returns a window sitting
+/// on a workspace that isn't even shown, and a click "through" empty desktop
+/// on the current workspace silently focuses/raises/moves motion onto that
+/// invisible window instead of whatever (if anything) is really there.
+pub(super) fn dwindow_is_visible(state: &CompState, w: &DWindow) -> bool {
+ let Some(id) = dwindow_wl_surface(w).and_then(|s| state.surface_to_id.get(&s).copied()) else { return false };
+ let wm = state.wm.borrow();
+ wm.window(id).is_some_and(|win| !win.minimized && win.workspace == wm.current_workspace())
+}
+
+/// Requests a client close its window, whichever kind it is.
+pub(crate) fn close_dwindow(w: &DWindow) {
+ if let Some(top) = w.toplevel() {
+ top.send_close();
+ } else if let Some(x11) = w.x11_surface() {
+ let _ = x11.close();
+ }
+}
+
+/// Focuses `id` in our own `WindowManager` *and* gives its surface real
+/// Wayland/X11 keyboard focus - without this, a window can be raised and
+/// tiled correctly yet never receive a single keystroke.
+pub(crate) fn focus_window(state: &mut CompState, id: WindowId) {
+ state.wm.borrow_mut().focus_window(id);
+ // Raises the window in smithay's own `Space` too, not just core's
+ // `order` - `Space` keeps a completely independent stacking order of
+ // its own, which is what actually renders on top *and* what
+ // `space.element_under` hit-tests against; `WindowManager::order`
+ // (which `focus_window` above already updates) has no effect on
+ // either. Without this, any focus path that doesn't also happen to
+ // raise `Space` manually (Alt-Tab, a dock's IPC "focus" dispatch,
+ // scratchpad show, the Snap-Layouts flyout, ...) left a window
+ // genuinely focused - keyboard input, core's own idea of "topmost"
+ // both correct - while it kept rendering *underneath* whatever was
+ // already on top, and a click on the visible (stale-topmost) window
+ // silently reached that one instead. "Focus doesn't bring a window to
+ // the front" and "clicking through a window that's fully covering
+ // another" are the same root cause, not two bugs. Previously only the
+ // plain-content-click branch in `handle_pointer_button` did this,
+ // manually, immediately before calling this function - every other
+ // caller went through unraised. Cheap even when the window is already
+ // topmost (`raise_element` on an already-last element is a no-op
+ // reinsertion), so unconditional here rather than gated on whether
+ // focus is actually changing.
+ raise_in_space(state, id);
+ state.pending.borrow_mut().push(CoreEvent::WindowFocused(id));
+ let surface = state.id_to_window.get(&id).and_then(dwindow_wl_surface);
+ // Routed through `set_keyboard_focus` (rather than calling
+ // `KeyboardHandle::set_focus` directly) so clipboard/primary-selection
+ // focus follows window focus too - see that method's doc comment.
+ state.set_keyboard_focus(surface);
+}
+
+/// Raises `id` to the top of smithay's own `Space` stacking order (see
+/// `focus_window`'s doc comment for why `Space`'s own order, separate from
+/// core's, has to be kept in sync) without touching core's focus or
+/// workspace state at all.
+///
+/// Split out of `focus_window` specifically so the udev/winit backends'
+/// post-IPC-mutation re-sync (`crate::input::focus_window`'s doc comment
+/// on *that* call site explains why it exists at all: an IPC-only focus
+/// change needs `Space` to catch up too) can re-raise the already-focused
+/// window without going through `WindowManager::focus_window` a second
+/// time. That core method has its own side effect of switching to the
+/// target's workspace if it differs from the current one - correct for a
+/// real focus change, but wrong here: calling it on a window that is
+/// already focused (just re-raising it for `Space`'s benefit) compared the
+/// still-current, still-on-its-old-workspace window against whatever
+/// `current_workspace` had just been set to by the same IPC mutation this
+/// re-sync is reacting to, and silently switched it right back --
+/// confirmed live as `srd dispatch activate workspace <id>` (and, by
+/// extension, any AGS workspace-switcher click going through the same IPC
+/// path) visibly changing `current_workspace` for a moment and then
+/// reverting within milliseconds, every time, unless the same IPC call
+/// also happened to change which window was focused. Exactly the same bug
+/// `main.rs`'s `sync()` was already fixed for (see its own doc comment) --
+/// this is a second call site with the identical unconditional-`focus_
+/// window`-reassertion shape, never fixed at the same time.
+pub(crate) fn raise_in_space(state: &mut CompState, id: WindowId) {
+ if let Some(w) = state.id_to_window.get(&id).cloned() {
+ state.space.raise_element(&w, true);
+ state.raise_pinned();
+ }
+}
+
+/// Re-syncs real Wayland/X11 keyboard focus to whatever `WindowManager`
+/// already considers focused, without changing what that is.
+///
+/// For callers where core's own focus already moved on its own --
+/// specifically `WindowManager::remove_window`'s fallback to
+/// `self.order.last()` when the just-closed window was the focused one --
+/// and only the Wayland/X11 side needs to catch up to it. Without this, the
+/// window core now considers focused (and renders as such) never actually
+/// receives a keystroke until it's clicked, since nothing told
+/// `set_keyboard_focus` focus had moved.
+///
+/// `focus_window` above is for the opposite direction: driving core's
+/// focus deliberately (a click, a keybinding) and syncing outward from
+/// that. This is "core already decided, catch the rest of the compositor
+/// up" - `wm.focus_window` must not be called again here, since the id
+/// core picked (or `None`, if nothing is left) is exactly what should win.
+pub(crate) fn sync_keyboard_focus(state: &mut CompState) {
+ let focused = state.wm.borrow().focused_id();
+ let surface = focused.and_then(|id| state.id_to_window.get(&id)).and_then(dwindow_wl_surface);
+ state.set_keyboard_focus(surface);
+}
diff --git a/crates/wayland/src/input/gestures.rs b/crates/wayland/src/input/gestures.rs
new file mode 100644
index 0000000..f88c633
--- /dev/null
+++ b/crates/wayland/src/input/gestures.rs
@@ -0,0 +1,131 @@
+//! `SUPER`+scroll and touchpad-swipe workspace switching.
+
+use crate::state::CompState;
+
+use super::keyboard::core_modifiers_from_xkb;
+use super::{notify_idle_activity, DRAG_MODIFIER};
+
+/// Modifier+scroll cycles workspaces, consuming the event.
+///
+/// Returns `true` if it handled the scroll, in which case the caller must
+/// *not* also forward it to the client. Generic over the input backend for
+/// the same reason the keyboard handler is: both backends deliver scroll
+/// through smithay's `PointerAxisEvent` trait.
+pub(crate) fn handle_workspace_scroll<B, E>(state: &mut CompState, event: &E) -> bool
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::PointerAxisEvent<B>,
+{
+ use smithay::backend::input::Axis;
+
+ notify_idle_activity(state);
+ if state.lock.locked {
+ return false;
+ }
+ let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state()));
+ if !mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) {
+ return false;
+ }
+ let Some(v) = event.amount(Axis::Vertical).filter(|v| *v != 0.0) else { return false };
+ // Scrolling down (positive) advances, matching `workspace, e+1`.
+ switch_workspace_relative(state, v > 0.0)
+}
+
+/// Switches to the next (`forward`) or previous workspace in id order,
+/// wrapping around, and fires the two follow-up broadcasts a plain
+/// `WindowManager::switch_workspace` call alone doesn't cover. The shared
+/// body behind every *relative* workspace switch - `SUPER`+scroll above,
+/// and a 3+-finger touchpad swipe (`handle_gesture_swipe_end` below) --
+/// pulled out here rather than duplicated a second time: both gaps below
+/// were found missing for the scroll gesture specifically during this same
+/// session, and nothing about either is scroll-only, so a second call site
+/// copy-pasting the same steps would have been one missed broadcast away
+/// from reintroducing the exact bug that was just fixed once already.
+/// Returns `false` (and does nothing) if there are no workspaces at all.
+fn switch_workspace_relative(state: &mut CompState, forward: bool) -> bool {
+ let mut wm = state.wm.borrow_mut();
+ let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect();
+ if ids.is_empty() {
+ return false;
+ }
+ let current = ids.iter().position(|&id| id == wm.current_workspace()).unwrap_or(0);
+ let next = if forward { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() };
+ wm.switch_workspace(ids[next]);
+ drop(wm);
+ // Without this, the switch above is invisible: nothing shows or hides
+ // a single window for the new workspace until `main.rs`'s `sync()`
+ // runs, which only happens when a polled event sets `dirty` - see
+ // `srdwm_core::Event::WorkspaceChanged`'s doc comment. Found live-
+ // testing the unrelated `ext_workspace_v1` protocol's own `activate`
+ // request, which has the identical problem; the scroll gesture had the
+ // exact same bug already, just never one anyone traced back this far.
+ state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged);
+ // Same reasoning as `foreign_toplevel::send_state`'s call sites: without
+ // this, a dock's workspace pill only ever tracked switches driven
+ // through `ext_workspace_handle_v1.activate` itself, going stale the
+ // moment a gesture (or any other non-protocol trigger) changed the
+ // active workspace instead.
+ crate::workspace::broadcast_active_workspace(state);
+ true
+}
+
+/// A 3+-finger touchpad swipe just started - resets the running horizontal
+/// offset `handle_gesture_swipe_update` accumulates into, or leaves it
+/// `None` while the session is locked so a swipe over the lock screen does
+/// nothing (matching every other pointer/keyboard path's "locked: no normal
+/// handling" rule - see this module's own doc comment).
+pub(crate) fn handle_gesture_swipe_begin<B, E>(state: &mut CompState, event: &E)
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::GestureBeginEvent<B>,
+{
+ notify_idle_activity(state);
+ state.gesture_swipe = if state.lock.locked { None } else { Some((event.fingers(), 0.0)) };
+}
+
+/// Accumulates one update's worth of horizontal motion into the swipe
+/// started by `handle_gesture_swipe_begin` - `delta_x` is relative to the
+/// *previous* update, not a running total (see `gesture_swipe`'s own doc
+/// comment on `CompState`), so summing here is the only way to know the
+/// swipe's real total distance once it ends.
+pub(crate) fn handle_gesture_swipe_update<B, E>(state: &mut CompState, event: &E)
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::GestureSwipeUpdateEvent<B>,
+{
+ if let Some((_, total_dx)) = state.gesture_swipe.as_mut() {
+ *total_dx += event.delta_x();
+ }
+}
+
+/// A touchpad swipe just ended - switches workspace if it was a genuine
+/// 3+-finger swipe past `SWIPE_THRESHOLD` and wasn't cancelled (a libinput
+/// gesture is marked cancelled when it doesn't resolve to a clean single
+/// direction, e.g. the fingers moved back and forth). Below the threshold
+/// or below 3 fingers, this does nothing - the same "did you mean it"
+/// floor a mis-clicked drag gets elsewhere in this file, and 2-finger
+/// motion is already handled as ordinary scroll (`PointerAxis`) rather
+/// than reaching here at all on a correctly configured touchpad.
+///
+/// Deliberately claimed entirely by the compositor rather than forwarded to
+/// the focused client, unlike pinch/hold (forwarded as-is in
+/// `udev::session`): `wp_pointer_gestures` swipe is specifically the
+/// 3/4-finger overview-style gesture, and the handful of desktops that
+/// support it at all (GNOME, sway, Hyprland) all reserve it for workspace
+/// switching the same way - there is no real client-side consumer to lose
+/// by not forwarding it. Swipe left (negative `total_dx`) advances to the
+/// next workspace, right goes back, matching macOS's own convention for
+/// swiping between spaces.
+const SWIPE_THRESHOLD: f64 = 60.0;
+
+pub(crate) fn handle_gesture_swipe_end<B, E>(state: &mut CompState, event: &E)
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::GestureEndEvent<B>,
+{
+ let Some((fingers, total_dx)) = state.gesture_swipe.take() else { return };
+ if event.cancelled() || fingers < 3 || total_dx.abs() < SWIPE_THRESHOLD {
+ return;
+ }
+ switch_workspace_relative(state, total_dx < 0.0);
+}
diff --git a/crates/wayland/src/input/keyboard.rs b/crates/wayland/src/input/keyboard.rs
new file mode 100644
index 0000000..ae367f4
--- /dev/null
+++ b/crates/wayland/src/input/keyboard.rs
@@ -0,0 +1,174 @@
+//! Keyboard key events: precise keybinding matching against
+//! `srdwm_core::keysyms`, VT switching, and the locked-session/native-lock
+//! password-entry path.
+
+use smithay::backend::input::{KeyState as BackendKeyState, KeyboardKeyEvent};
+use smithay::backend::session::Session as _;
+use smithay::input::keyboard::FilterResult;
+use smithay::utils::SERIAL_COUNTER;
+
+use srdwm_core::{Event as CoreEvent, Modifiers};
+
+use crate::state::CompState;
+
+/// Shared between the winit (nested) and udev (bare-TTY) backends: both
+/// deliver keyboard events through smithay's generic `KeyboardKeyEvent`
+/// trait, so the precise-keybinding-matching logic (see the module docs)
+/// only needs to exist once.
+pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend, E: KeyboardKeyEvent<B>>(state: &mut CompState, event: &E) {
+ super::notify_idle_activity(state);
+ let keycode = event.key_code();
+ let key_state = event.state();
+ let time = event.time_msec();
+ let serial = SERIAL_COUNTER.next_serial();
+ let Some(keyboard) = state.seat.get_keyboard() else { return };
+
+ // While the session is locked, every key goes to the lock surface and
+ // *nothing* is treated as a WM keybinding. Skipping this would leave the
+ // lock trivially bypassable - the config binds spawn commands
+ // (`Mod4+Return` opens a terminal), so honouring bindings here would let
+ // anyone at a locked screen run arbitrary programs.
+ if state.lock.locked {
+ // A native lock (`crate::native_lock`) has no external client
+ // surface to forward to at all - srdwm is its own locker, so
+ // every keystroke feeds the password buffer directly instead.
+ // Only on press: a character is typed on key-down, matching
+ // ordinary text input, and password/BackSpace/Return/Escape
+ // handling only make sense once per physical keystroke, not once
+ // per press *and* release.
+ if state.lock.native.is_some() {
+ if key_state == BackendKeyState::Pressed {
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |data, mods, handle| {
+ // `keysym_to_utf8` on the already-resolved keysym
+ // (rather than the state-aware `xkb_state_key_get_
+ // utf8` xkbcommon's own docs recommend) is a
+ // deliberate simplification: correct for plain
+ // ASCII/shifted-symbol passwords, which is the
+ // overwhelming common case; the gap is dead-key/
+ // compose sequences spanning more than one keypress,
+ // which would just make that one character not match
+ // rather than ever falsely succeed - a usability
+ // rough edge, not a security one. Computed before
+ // `keysym_name_for` below, which takes `handle` by
+ // value.
+ let utf8 = xkbcommon::xkb::keysym_to_utf8(handle.modified_sym());
+ let name = keysym_name_for(handle).unwrap_or_default();
+ data.native_lock_key(&name, &utf8, mods.caps_lock);
+ FilterResult::Intercept(())
+ });
+ } else {
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Intercept(()));
+ }
+ return;
+ }
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Forward);
+ return;
+ }
+
+ let bound_keys = state.bound_keys.clone();
+ let matched: Option<(String, Modifiers)> =
+ keyboard.input(state, keycode, key_state, serial, time, move |data, mods, handle| {
+ let modifiers = core_modifiers_from_xkb(mods);
+ // `Ctrl+Alt+F1`..`F12` (xkb emits these as the `XF86Switch_VT_1`..
+ // `_12` keysyms, not a plain function-key + modifier combo) --
+ // handled here, by raw keysym *value* rather than name, since
+ // matching a name string wrong fails silently and looks
+ // identical to this never having been implemented at all (it
+ // wasn't, until now: reported live, the user had to leave the
+ // graphical session entirely and log in on a different TTY to
+ // get a shell back after srdwm went down, because nothing ever
+ // told the session to switch away). Values are contiguous
+ // (0x1008FE01..=0x1008FE0C, xkbcommon's `keysyms.rs`), so `raw -
+ // KEY_XF86SWITCH_VT_1 + 1` is the target VT. Udev/bare-TTY
+ // backend only - `data.udev` is `None` under the nested winit
+ // backend, where VT switching is meaningless, so this is a
+ // no-op there rather than an error, same as every other
+ // udev-only feature in this module.
+ const KEY_XF86SWITCH_VT_1: u32 = 0x1008_FE01;
+ const KEY_XF86SWITCH_VT_12: u32 = 0x1008_FE0C;
+ let raw = handle.modified_sym().raw();
+ if (KEY_XF86SWITCH_VT_1..=KEY_XF86SWITCH_VT_12).contains(&raw) {
+ if key_state == BackendKeyState::Pressed {
+ if let Some(udev) = data.udev.as_mut() {
+ let vt = (raw - KEY_XF86SWITCH_VT_1 + 1) as i32;
+ if let Err(e) = udev.session.change_vt(vt) {
+ log::warn!("udev: change_vt({vt}) failed: {e}");
+ }
+ }
+ }
+ return FilterResult::Intercept((String::new(), modifiers));
+ }
+ match keysym_name_for(handle) {
+ Some(name) if bound_keys.contains(&srdwm_core::key_combo_string(modifiers, &name)) => {
+ FilterResult::Intercept((name, modifiers))
+ }
+ _ => FilterResult::Forward,
+ }
+ });
+
+ match key_state {
+ BackendKeyState::Pressed => {
+ // An empty `key_name` is the VT-switch case above, already
+ // fully handled inside the closure - it isn't a real
+ // keybinding and must not start a repeat timer or fire a
+ // `CoreEvent::KeyPress` (`Lua` config has nothing bound to `""`,
+ // so this would be harmless either way, but skipping it is both
+ // cheaper and clearer than relying on that).
+ if let Some((key_name, modifiers)) = matched {
+ if !key_name.is_empty() {
+ state.begin_repeat(keycode, &key_name, modifiers);
+ state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers });
+ }
+ }
+ }
+ // Any release ends a repeat of *that* key; releasing an unrelated
+ // key must not stop it.
+ BackendKeyState::Released => state.end_repeat(keycode),
+ }
+ // Unmatched keys were already forwarded to the focused client by
+ // `FilterResult::Forward` inside the closure above.
+}
+
+/// Translates the effective xkb keysym for this keypress into the same
+/// `"Return"`/`"a"`/`"F5"`-style name `srdwm_core::keysyms` uses, so a
+/// binding written once in Lua resolves identically on X11 and Wayland.
+pub(crate) fn keysym_name_for(handle: smithay::input::keyboard::KeysymHandle<'_>) -> Option<String> {
+ // `raw_syms()` - the keycode's level-0 (unshifted) symbol for the
+ // *current* layout, not `modified_sym()` (what Shift actually turns it
+ // into). For a keybinding like `Super+Shift+2`, matching against
+ // `modified_sym()` looked up whatever Shift+2 really produces on the
+ // active layout - `@` on US, and something else again on most other
+ // layouts - which never equals the literal name `"2"` the Lua config
+ // binds against. Every `Super+Shift+<number>` binding
+ // (`keybindings.lua`'s `workspace.move_window`) silently never matched
+ // anything, indistinguishable from not being bound at all. Shift is
+ // still fully honored as a *modifier* - `core_modifiers_from_xkb`
+ // reads it independently of which symbol this function returns - this
+ // only changes which symbol *name* represents "the 2 key", the same
+ // physical-key-plus-modifier-flags model every other keybinding system
+ // (Hyprland, i3, sway) uses. The other caller of this function (the
+ // native lock's password entry) only ever compares the result against
+ // non-shift-sensitive names (`BackSpace`/`Return`/`Escape`), so this
+ // doesn't change that path's behavior at all - real character input
+ // there already goes through `keysym_to_utf8(handle.modified_sym())`
+ // separately, untouched by this.
+ let sym = handle.raw_syms().first().copied().unwrap_or_else(|| handle.modified_sym());
+ srdwm_core::keysyms::keysym_to_name(sym.raw())
+}
+
+pub(crate) fn core_modifiers_from_xkb(mods: &smithay::input::keyboard::ModifiersState) -> Modifiers {
+ let mut m = Modifiers::empty();
+ if mods.shift {
+ m |= Modifiers::SHIFT;
+ }
+ if mods.ctrl {
+ m |= Modifiers::CTRL;
+ }
+ if mods.alt {
+ m |= Modifiers::ALT;
+ }
+ if mods.logo {
+ m |= Modifiers::SUPER;
+ }
+ m
+}
diff --git a/crates/wayland/src/input/layers.rs b/crates/wayland/src/input/layers.rs
new file mode 100644
index 0000000..718d83f
--- /dev/null
+++ b/crates/wayland/src/input/layers.rs
@@ -0,0 +1,169 @@
+//! `zwlr_layer_shell_v1` pointer hit-testing (bars, docks, launchers) and
+//! the layer-driven maximize-geometry computation both backends' `monitors()`
+//! need.
+
+use smithay::desktop::{layer_map_for_output, WindowSurfaceType};
+use smithay::output::Output;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::Resource as _;
+use smithay::utils::{Logical, Point};
+use smithay::wayland::compositor::with_states;
+use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone, Layer, LayerSurfaceCachedState};
+
+use crate::state::CompState;
+
+/// Topmost layer-shell surface (if any) under `pos`, checked in the same
+/// above-everything-else stacking order `space_render_elements` renders
+/// `Overlay`/`Top` layers in (bars, launchers, notifications, lock UIs).
+/// `Background`/`Bottom` layers (wallpapers) deliberately aren't checked
+/// here: nothing in scope for the daily-driver gate needs pointer input
+/// routed to them, and space windows should stay clickable over a
+/// wallpaper.
+/// `pos` is in the global space; layer geometry is relative to its own
+/// output, so the pointer is translated into output-local coordinates
+/// before hit-testing and the result translated back out.
+/// Only checked for `Overlay`/`Top` before a window hit-test, and again for
+/// `Bottom`/`Background` after one comes up empty - see the two call
+/// sites in `handle_pointer_button`/`handle_pointer_position` for why it's
+/// split rather than one four-layer loop here. A `Bottom`/`Background`
+/// surface (a desktop-icons layer, a wallpaper daemon that wants clicks) is
+/// meant to sit *behind* normal windows, so a window covering that point
+/// should still get the click; `Overlay`/`Top` (an on-screen keyboard, a
+/// bar, a dock) are meant to sit in front of everything, windows included.
+///
+/// Was `Overlay`/`Top` only, full stop - a `Bottom`-layer surface was
+/// silently unclickable no matter what, since nothing else in
+/// `handle_pointer_button` ever checked layers at all. Not the cause of
+/// the live "clicking the dock does nothing" report (confirmed: that dock
+/// uses `Layer::Top`, which was already checked), but a real, separate gap
+/// found while chasing it - worth closing regardless of whether anything
+/// currently deployed sits at `Bottom`/`Background` yet.
+pub(super) fn layer_surface_under_layers(state: &CompState, pos: Point<f64, Logical>, layers: [Layer; 2]) -> Option<(WlSurface, Point<i32, Logical>)> {
+ let entry = state.output_at(pos)?;
+ let origin = entry.location;
+ let local = pos - origin.to_f64();
+ let map = layer_map_for_output(&entry.output);
+ for layer_kind in layers {
+ // Not `map.layer_under(layer_kind, local)` - that hands back only
+ // the single topmost surface whose *bounding box* contains `local`,
+ // and if that one surface's own input region excludes the point
+ // (its `surface_under` below returns `None`), the old code gave up
+ // on this whole layer-kind rather than trying whatever real,
+ // clickable surface is stacked underneath it. A bbox-only pick is
+ // exactly wrong the moment two surfaces on the same layer-kind
+ // overlap - a transparent, mapped-but-mostly-empty surface (a
+ // backdrop-dismiss popup, concretely: `Overview`'s own bbox-wide
+ // fallback region was exactly this shape before it was fixed
+ // AGS-side) sitting in front of a real one in z-order would
+ // silently swallow every click and even every hover/motion event
+ // meant for the surface underneath, with no way to reach it at
+ // all. Walking every candidate on this layer-kind, topmost first
+ // (`.rev()`, matching `layer_under`'s own z-order convention), and
+ // falling through to the next when a candidate's real input region
+ // doesn't cover the point, is what `layer_under` alone can't do.
+ for layer in map.layers_on(layer_kind).rev() {
+ let Some(geo) = map.layer_geometry(layer) else { continue };
+ if !geo.to_f64().contains(local) {
+ continue;
+ }
+ // Temporary: verifying the `layer_surfaces_shown_once` fix
+ // (state/layers.rs) actually stops a reused `wl_surface`'s
+ // stale layer-shell entry from outliving its role destroy --
+ // live-reproduced this session as a full-monitor click-catcher
+ // popup whose hit-tested geometry came back wider than the
+ // real output after several open/close cycles. Remove once a
+ // restart confirms the geometry stays sane across repeated
+ // popup toggles.
+ let local_in_surface = local - geo.loc.to_f64();
+ // `None` here means "no region ever committed" - per-protocol
+ // that means the *whole* surface is input-sensitive, not that
+ // nothing is, so it is its own distinct, meaningful answer from
+ // `Some([])` (a region was committed and it is empty).
+ let region_dump = with_states(layer.wl_surface(), |states| {
+ states.cached_state.get::<smithay::wayland::compositor::SurfaceAttributes>().current().input_region.as_ref().map(|r| r.rects.clone())
+ });
+ log::info!(
+ "layer_hit_test: layer={:?} namespace={:?} surface={:?} geo={:?} local_in_surface={:?} input_region={:?}",
+ layer_kind,
+ layer.namespace(),
+ layer.wl_surface().id(),
+ geo,
+ local_in_surface,
+ region_dump
+ );
+ if let Some((surface, surface_loc)) = layer.surface_under(local - geo.loc.to_f64(), WindowSurfaceType::ALL) {
+ return Some((surface, origin + geo.loc + surface_loc));
+ }
+ }
+ }
+ None
+}
+
+pub(super) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
+ layer_surface_under_layers(state, pos, [Layer::Overlay, Layer::Top])
+}
+
+/// The `Bottom`/`Background` half of the same lookup - see
+/// `layer_surface_under_layers`'s doc comment for the ordering rationale.
+pub(super) fn background_layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
+ layer_surface_under_layers(state, pos, [Layer::Bottom, Layer::Background])
+}
+
+/// `full` with only a top-anchored layer surface's exclusive zone (a menu
+/// bar) subtracted back out - see `Monitor::maximize_geometry`'s own doc
+/// comment for why maximize needs this third rect, distinct from both
+/// `geometry` (every zone subtracted) and `full_geometry` (none). Shared by
+/// both backends' `monitors()`, same as everything else in this module.
+/// Deliberately re-derived from the layer list rather than reusing
+/// `non_exclusive_zone()`: that smithay helper folds every anchor
+/// together with no way to ask it to skip one edge - see below for which
+/// edges this now shrinks for and why.
+///
+/// Shrinks for a reservation on *any* edge (top, bottom, left, or right),
+/// not top only - reported live as a maximized window's own bottom edge
+/// and border ending up underneath a bottom-anchored dock, indistinguishable
+/// from the dock not rendering at all. An earlier version of this
+/// function shrank only for a top-anchored bar, on the reasoning that
+/// maximize should be able to "go past" a dock while fullscreen (which
+/// already ignores every zone, via `full_geometry`) covers the case that
+/// wants the screen entirely to itself - but no other edge actually
+/// benefits from that distinction the way a top menu bar does, and
+/// respecting every edge here is what every mainstream desktop's own
+/// maximize convention already does. Fullscreen is unaffected - it never
+/// called this function, and still doesn't.
+pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) -> srdwm_core::Rect {
+ let mut rect = full;
+ // `exclusive_zone`/`margin` are logical (a layer-shell client reports
+ // its own reservation the same way every other layer-shell geometry
+ // is expressed), while `full` is physical pixels - same unit
+ // mismatch `Platform::monitors()` needed fixing for, and the same
+ // fix: scale the logical amount into physical pixels before touching
+ // a physical rect with it. Left unconverted, a scaled output's
+ // maximize target shrank by the wrong number of physical rows/columns
+ // for its own bar/dock (too few at scale < 1.0, too many above 1.0).
+ let scale = output.current_scale().fractional_scale();
+ for layer in layer_map_for_output(output).layers() {
+ let data = with_states(layer.wl_surface(), |states| *states.cached_state.get::<LayerSurfaceCachedState>().current());
+ let ExclusiveZone::Exclusive(amount) = data.exclusive_zone else { continue };
+ let scaled = |margin: i32| ((amount as f64 + margin as f64) * scale).round().max(0.0) as i32;
+ if data.anchor.contains(Anchor::TOP) && !data.anchor.contains(Anchor::BOTTOM) {
+ let shrink = scaled(data.margin.top);
+ rect.y += shrink;
+ rect.height = rect.height.saturating_sub(shrink as u32);
+ }
+ if data.anchor.contains(Anchor::BOTTOM) && !data.anchor.contains(Anchor::TOP) {
+ let shrink = scaled(data.margin.bottom);
+ rect.height = rect.height.saturating_sub(shrink as u32);
+ }
+ if data.anchor.contains(Anchor::LEFT) && !data.anchor.contains(Anchor::RIGHT) {
+ let shrink = scaled(data.margin.left);
+ rect.x += shrink;
+ rect.width = rect.width.saturating_sub(shrink as u32);
+ }
+ if data.anchor.contains(Anchor::RIGHT) && !data.anchor.contains(Anchor::LEFT) {
+ let shrink = scaled(data.margin.right);
+ rect.width = rect.width.saturating_sub(shrink as u32);
+ }
+ }
+ rect
+}
diff --git a/crates/wayland/src/input/pointer.rs b/crates/wayland/src/input/pointer.rs
new file mode 100644
index 0000000..7b3bbce
--- /dev/null
+++ b/crates/wayland/src/input/pointer.rs
@@ -0,0 +1,683 @@
+//! Pointer motion, button presses, and cursor-shape resolution.
+
+use smithay::backend::input::ButtonState as BackendButtonState;
+use smithay::desktop::{layer_map_for_output, WindowSurfaceType};
+use smithay::input::pointer::{ButtonEvent, MotionEvent};
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::utils::{Logical, Point, SERIAL_COUNTER};
+use smithay::wayland::shell::wlr_layer::KeyboardInteractivity;
+
+use srdwm_core::{TitlebarHit, WindowId};
+
+use crate::state::CompState;
+
+use super::focus::{close_dwindow, dwindow_is_visible, dwindow_wl_surface, focus_window};
+use super::keyboard::core_modifiers_from_xkb;
+use super::layers::{background_layer_surface_under, layer_surface_under};
+use super::{notify_idle_activity, DRAG_MODIFIER};
+
+/// `WindowManager::hit_test`, but substituting each window's currently
+/// *animated* rect (if it has one active in `state.window_anims`) for its
+/// final `geometry` - see `hit_test_with`'s own doc comment in
+/// `crates/core/src/manager/hittest.rs` for why plain `hit_test` alone gets
+/// this wrong during a maximize/fullscreen/snap toggle or a new window's
+/// open-slide. Every decoration hit-test call site in this module goes
+/// through this now instead of calling `hit_test` on the borrowed
+/// `WindowManager` directly.
+fn hit_test_animated(state: &CompState, x: i32, y: i32) -> Option<(WindowId, TitlebarHit)> {
+ state.wm.borrow().hit_test_with(x, y, |id, geometry| {
+ let animated = state.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(geometry);
+ // Also corrects for a client whose real committed size differs
+ // from what was requested (a terminal's cell-quantized size, most
+ // commonly) - see `effective_frame`'s own doc comment. Without
+ // this, the resize-margin/border hit-test zone stayed sized to the
+ // *requested* rect even after the border itself moved to match the
+ // real one, so the clickable edge and the visible edge disagreed
+ // again, just like the border and the desktop background used to.
+ state.effective_frame(id, animated)
+ })
+}
+
+/// Re-resolves and re-asserts real Wayland pointer focus at `pos` - i.e.
+/// re-runs the exact same layer-shell/decoration/content/background
+/// hit-testing `handle_pointer_position` always did, and calls
+/// `pointer.motion()` with whatever it finds, but *without* sending
+/// `wl_pointer.frame` (callers decide when their own batch of events is
+/// done) and without any of `handle_pointer_position`'s other side effects
+/// (cursor shape, focus-follows-mouse, drag/resize updates) - those only
+/// make sense on an actual motion event, not a button press.
+///
+/// Extracted so [`handle_pointer_button`] can call this immediately before
+/// delivering a click, rather than only ever trusting whatever the *last*
+/// real motion event happened to leave `PointerHandle`'s own focus at.
+/// Those can disagree: confirmed live via a temporary diagnostic (since
+/// removed) that `space.element_under(pos)` - srdwm's own, freshly
+/// computed on every click - and
+/// `PointerHandle::current_focus()` - Wayland's, last set by whichever
+/// motion event happened to run before this click - disagreed on a real
+/// user's real clicks, inconsistently, sometimes on the very same window.
+/// A click landing on stale/no Wayland focus reads exactly like "clicking
+/// doesn't work" or "the cursor isn't where clicking happens," even though
+/// srdwm's own idea of what's under the pointer was correct the whole
+/// time. Calling this right before every button event closes that gap
+/// regardless of why focus went stale, rather than chasing the exact
+/// staleness trigger (rapid clicks, a tap-to-click event with no
+/// intervening motion delta, etc.) one cause at a time.
+#[allow(clippy::type_complexity)]
+fn refresh_pointer_focus(
+ state: &mut CompState,
+ pos: Point<f64, Logical>,
+ time: u32,
+) -> (Option<(WindowId, TitlebarHit)>, bool, bool, Option<WindowId>, Option<(WlSurface, Point<f64, Logical>)>) {
+ // Checked before literally everything else, including layer-shell --
+ // see `elements::popup_surface_under`'s own doc comment for why: a
+ // popup (tooltip, dropdown, right-click menu) always renders on top of
+ // everything else, popups on their own parent's content and layer-shell
+ // bars/docks alike, and hit-testing has to match that same priority or
+ // a click/scroll over an open popup silently lands on whatever's
+ // underneath it instead.
+ let popup_hit = crate::elements::popup_surface_under(state, pos);
+ let layer_hit = layer_surface_under(state, pos);
+ // Broadened, not just layer-shell: both a layer surface and an open
+ // popup are transient client UI that should suppress WM-level
+ // decoration-cursor guessing and focus-follows-mouse the same way (see
+ // both call sites below) - hovering a dropdown menu must not refocus
+ // whatever window happens to sit underneath it.
+ let over_layer_surface = layer_hit.is_some() || popup_hit.is_some();
+ let hit = hit_test_animated(state, pos.x as i32, pos.y as i32);
+ let under = state
+ .space
+ .element_under(pos)
+ .filter(|(w, _)| dwindow_is_visible(state, w))
+ .map(|(w, loc)| (w.clone(), loc));
+ let over_content = under.is_some();
+ // Whichever core window the pointer is over right now, decoration or
+ // content - `None` while over a layer-shell surface or bare desktop.
+ // Only `handle_pointer_position` actually uses this (focus-follows-
+ // mouse), but it needs `under` before that's consumed by the match
+ // below, so it's computed here rather than recomputed by the caller.
+ let hovered_id = hit
+ .map(|(id, _)| id)
+ .or_else(|| under.as_ref().and_then(|(window, _)| dwindow_wl_surface(window)).and_then(|s| state.surface_to_id.get(&s).copied()));
+
+ let Some(pointer) = state.seat.get_pointer() else { return (hit, over_layer_surface, over_content, hovered_id, None) };
+ // Freshly resolved target from ordinary hit-testing - overridden below
+ // by `pointer_button_grab` when a button is held, per its own doc
+ // comment (the Wayland implicit-grab rule).
+ let resolved: Option<(WlSurface, Point<f64, Logical>)> = if let Some((surface, loc)) = popup_hit {
+ Some((surface, loc.to_f64()))
+ } else if let Some((surface, loc)) = layer_hit {
+ Some((surface, loc.to_f64()))
+ } else if hit.is_some() {
+ None // Over our own decoration - no client focus.
+ } else if let Some((window, loc)) = &under {
+ // `window.toplevel()` is only ever `Some` for a native xdg-shell
+ // surface - it's `None` for every XWayland window, and even for a
+ // plain xdg-shell one it's always the *root* surface regardless of
+ // which subsurface the pointer is actually over (video/GL overlays,
+ // some GTK/Electron popups). Either way that meant pointer focus
+ // landed on the wrong surface - or no surface at all, for X11
+ // clients - and the click coordinates were relative to the window
+ // root rather than whatever was actually under the cursor.
+ // `Window::surface_under` is smithay's own hit-test for this: it
+ // walks the real surface tree (subsurfaces and popups included) and
+ // unifies the xdg-shell/X11 cases the way `dwindow_wl_surface` does
+ // elsewhere in this module.
+ //
+ // `loc` (from `Space`) is the window's raw *buffer*-origin in screen
+ // space, NOT its visible top-left - see `sync_geometry`'s own doc
+ // comment (`state/geometry.rs`), which positions every window via
+ // `map_element(w, (geom.x - content_offset.x, ...))` *specifically*
+ // so that `pos - loc` alone already lands in the buffer-local
+ // coordinates `Window::surface_under` expects (confirmed against
+ // smithay 0.7.0's own source: the ordinary toplevel branch hands
+ // `point` straight through with a hardcoded `(0, 0)` offset, unlike
+ // its sibling popup branch a few lines above, which does add
+ // `self.geometry().loc` - so a toplevel's `point` has to already
+ // be buffer-local, and `map_element`'s own placement is what makes
+ // `pos - loc` be exactly that with no further adjustment needed).
+ //
+ // A previous version of this line added `content_offset` back a
+ // *second* time (`pos - loc + content_offset`), reasoning that
+ // `loc` was the visible position and needed shifting back to
+ // buffer-local - but `sync_geometry` had already done that
+ // shifting into `loc` itself, so this double-applied it: every
+ // click on a CSD window with a nonzero shadow margin (GTK4 clients
+ // - Firefox concretely, on its own titlebar/tab-strip buttons
+ // specifically, since that's real content on an undecorated
+ // window, not srdwm's own decoration) landed `content_offset`
+ // *past* whatever was actually clicked, in the opposite direction
+ // from the original (pre-any-fix) bug. Both versions were wrong in
+ // opposite directions; plain `pos - loc` is what `sync_geometry`'s
+ // own contract actually calls for.
+ let win_relative = pos - loc.to_f64();
+ window.surface_under(win_relative, WindowSurfaceType::ALL).map(|(surface, offset)| (surface, (*loc + offset).to_f64()))
+ } else {
+ // Bare desktop, no window there either - last chance for a
+ // `Bottom`/`Background` layer surface (see
+ // `layer_surface_under_layers`'s doc comment) before giving up.
+ background_layer_surface_under(state, pos).map(|(surface, loc)| (surface, loc.to_f64()))
+ };
+ // `pointer_button_grab`'s own lock is deliberately skipped whenever a
+ // real Wayland-level grab is active (`pointer.is_grabbed()`) - most
+ // concretely, a client-initiated `wl_data_device` drag-and-drop
+ // (`DnDGrab`, installed the moment a client calls `start_drag`, e.g. a
+ // browser tab being torn out into another window). smithay's own
+ // `DnDGrab::motion` ignores this call's `focus` argument for the
+ // client-facing side (it explicitly calls `handle.motion(data, None,
+ // event)`, since no client gets ordinary pointer focus mid-drag) but
+ // *does* feed the same `focus` straight into `update_focus`, which is
+ // what actually decides the current drop target as the cursor moves.
+ // Keeping the origin-surface lock active here as well meant that value
+ // stayed pinned to whichever window the drag *started* over for the
+ // entire gesture, so `update_focus` could never see a second window as
+ // the drop target no matter where the cursor actually went - reported
+ // live as not being able to drag a tab from one window onto another.
+ // The lock's own reason for existing (a GTK drag recognizer treating a
+ // mid-gesture `leave` as "abort", see this field's own doc comment)
+ // only applies to *ordinary* pointer motion, which is exactly the case
+ // `is_grabbed()` being false identifies - once a real grab has taken
+ // over, that grab's own implementation is already responsible for
+ // routing enter/leave correctly, and needs the true, freshly-resolved
+ // surface to do it, not a stale one.
+ let delivery = if pointer.is_grabbed() { resolved.clone() } else { state.pointer_button_grab.clone().or_else(|| resolved.clone()) };
+ if let Some((surface, origin)) = delivery {
+ // `MotionEvent.location` is documented on `smithay::input::pointer::
+ // MotionEvent` itself as "Location of the pointer in compositor
+ // space" - i.e. global, the same space `pos` is already in.
+ // `PointerHandle::motion`'s own `focus` parameter carries `origin`
+ // specifically so smithay can compute the surface-relative
+ // coordinate *itself* (`event.location - loc`, see `PointerInternal
+ // ::motion` in smithay's `input/pointer/mod.rs`) before handing it
+ // to the client and storing the *global* value in its own internal
+ // `self.location` (what `PointerHandle::current_location()` later
+ // returns). Subtracting `origin` here as well, before this call,
+ // fed the client `pos - origin - origin` - doubly-offset, and
+ // wrong in a way that grows with a window's distance from the
+ // screen origin - while also corrupting smithay's own idea of
+ // "where is the pointer" for anything else that reads
+ // `current_location()`. `pos` unmodified, letting smithay subtract
+ // `origin` exactly once, is what every other call site in this
+ // file (and this same function's own `None`/lock-surface branches)
+ // already does correctly.
+ pointer.motion(state, Some((surface, origin)), &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
+ } else {
+ pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
+ }
+ (hit, over_layer_surface, over_content, hovered_id, resolved)
+}
+
+pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Logical>, time: u32) {
+ notify_idle_activity(state);
+ // Locked: pointer motion goes to the lock surface only. No hit-testing
+ // against windows/decorations, so no hover, no drag, no resize.
+ if state.lock.locked {
+ let surface = state.any_lock_surface().cloned();
+ if let Some(pointer) = state.seat.get_pointer() {
+ let focus = surface.map(|s| (s, Point::from((0, 0)).to_f64()));
+ pointer.motion(state, focus, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
+ pointer.frame(state);
+ }
+ return;
+ }
+
+ // Tells core which monitor the pointer is physically over right now --
+ // core has no pointer of its own to know this (see `pointer_monitor`'s
+ // own doc comment), and `add_window`'s target-monitor fallback needs
+ // it for the one case the *focused* window's monitor can't answer:
+ // nothing focused on whichever monitor the user is actually at when
+ // launching something new. `full_geometry`, not the bar-shrunk
+ // `geometry` - this is "which physical screen is this pixel on", not
+ // a work-area question. `None` if the pointer is somehow outside every
+ // known monitor (shouldn't happen given `UdevState::bounds()` already
+ // clamps to their union, but a real `None` here is honest rather than
+ // guessing).
+ {
+ let mut wm = state.wm.borrow_mut();
+ let current = wm.monitors().iter().find(|m| m.full_geometry.contains_point(pos.x as i32, pos.y as i32)).map(|m| m.id);
+ wm.set_pointer_monitor(current);
+ }
+ let (hit, over_layer_surface, over_content, hovered_id, _) = refresh_pointer_focus(state, pos, time);
+ // The only pointer-position telemetry this compositor exposes to
+ // anything outside itself - kept at `trace` (off by default, `RUST_LOG`
+ // enables it same as any other target here) rather than removed
+ // outright: a peer session building against this compositor over IPC
+ // pointed out that without *some* "where is the pointer right now" oracle,
+ // a synthetic-input tool has no way to tell whether it moved the pointer
+ // at all versus landed somewhere unexpected, short of corner-clamping (4
+ // fixed points) or hover feedback (binary, only over a reactive widget).
+ // Every earlier version of this line ran at `warn`, unconditionally on --
+ // see `docs/TODO.md`'s matching cleanup entry for why that was too loud
+ // for daily use, not for why the telemetry itself was ever the problem.
+ log::trace!("pointer motion pos={:?} hit={hit:?}", (pos.x, pos.y));
+ // Titlebar button hover highlighting (explicitly requested, see
+ // docs/TODO.md) - `Drag`/`Resize` aren't buttons, so only the three
+ // real ones count. Compared against the previous value rather than
+ // set unconditionally so an unchanged hover (the overwhelmingly common
+ // case: most motion events land on the same button, or on none at all)
+ // doesn't force a redraw every single pointer-motion event.
+ let new_hover = hit.and_then(|(id, h)| matches!(h, srdwm_core::TitlebarHit::Close | srdwm_core::TitlebarHit::Minimize | srdwm_core::TitlebarHit::Maximize).then_some((id, h)));
+ // Compared as just `(id, hit)`, ignoring the `Instant` already stored
+ // - the field itself carries a timestamp, but "is this the same hover
+ // as before" must not depend on it, or every motion event within the
+ // same button would read as a *new* hover and keep resetting the
+ // glyph-reveal animation's own start time back to zero.
+ let currently_hovering = state.hovered_titlebar_button.map(|(id, h, _)| (id, h));
+ if new_hover != currently_hovering {
+ let old = state.hovered_titlebar_button.take();
+ state.hovered_titlebar_button = new_hover.map(|(id, h)| (id, h, std::time::Instant::now()));
+ // Both windows need a fresh signature check: the newly-hovered one
+ // (to actually draw the highlight) and the previously-hovered one,
+ // if it's a *different* window, to clear its own highlight again.
+ if let Some((id, _, _)) = old {
+ state.redraw_decoration_buffer(id);
+ }
+ if let Some((id, _)) = new_hover {
+ state.redraw_decoration_buffer(id);
+ }
+ }
+ let Some(pointer) = state.seat.get_pointer() else { return };
+ // `PointerHandle::motion`/`button`/`axis` only queue the event with the
+ // active grab - nothing sends `wl_pointer.frame` on its own (confirmed
+ // reading smithay's `DefaultGrab`: its `motion`/`button` impls call
+ // straight through to the handle and never call `frame`). `frame` is
+ // what tells a client "the events since the last frame are one atomic
+ // update, process them now" - required by the protocol since
+ // `wl_pointer` version 5, and this compositor advertises v9. Without
+ // it, any client that correctly waits for `frame` before acting on
+ // motion/button state (most modern toolkits, confirmed live: neither
+ // Firefox nor wezterm registered a click or a drag-selection, in both
+ // cases with the cursor sitting squarely on the target) never actually
+ // processes what it was sent, even though every event up to this point
+ // was individually correct. This is likely the real root cause behind
+ // this whole session's "clicking/scrolling doesn't work" reports --
+ // every fix so far (subsurface routing, decoration geometry, app_id)
+ // was real and necessary, but none of them could have mattered if the
+ // client was never told to look at what it received.
+ pointer.frame(state);
+
+ update_cursor_shape(state, hit, over_layer_surface, over_content);
+
+ let mut wm = state.wm.borrow_mut();
+ let dragging_or_resizing = wm.is_dragging() || wm.is_resizing();
+ if wm.is_dragging() {
+ wm.update_drag(pos.x as i32, pos.y as i32);
+ } else if wm.is_resizing() {
+ wm.update_resize(pos.x as i32, pos.y as i32);
+ }
+ let focused = wm.focused_id();
+ // `general.focus_follows_mouse`: hovering a *different* window focuses
+ // it, no click needed - classic X11 sloppy focus. Gated on `hit`/
+ // `under` actually landing on a window (not a layer surface or bare
+ // desktop) and on not already being mid-drag/resize, where the pointer
+ // sweeps over unrelated windows constantly and none of that should
+ // steal focus from whatever's actually being dragged. `hovered_id !=
+ // focused` both skips redundant work on every one of the many motion
+ // events a stationary pointer over an already-focused window still
+ // generates, and is what makes `auto_raise` (below) only fire on an
+ // actual focus change rather than every motion tick too.
+ let focus_follow_target =
+ (wm.focus_follows_mouse && !dragging_or_resizing && !over_layer_surface).then_some(hovered_id).flatten().filter(|id| Some(*id) != focused);
+ if let Some(id) = focus_follow_target {
+ if wm.auto_raise {
+ // `raise_window` alone here, not `focus_window` - the actual
+ // core + real Wayland/X11 keyboard focus change happens once,
+ // below, through the same `focus_window` free function every
+ // click-driven focus change already goes through (sets real
+ // keyboard focus too, which `WindowManager::focus_window`
+ // alone does not).
+ wm.raise_window(id);
+ }
+ }
+ drop(wm);
+ if let Some(id) = focus_follow_target {
+ focus_window(state, id);
+ }
+ if dragging_or_resizing {
+ if let Some(id) = focused {
+ state.sync_geometry(id);
+ }
+ }
+}
+
+/// Sets the pointer to a resize-direction shape while hovering (or
+/// actively dragging) one of our own decoration's resize edges, and back
+/// to the default arrow when leaving our decoration for anything else.
+///
+/// Only ever *forces* `cursor_status` for our own decoration - never while
+/// `layer_hit`/client content has focus, since a client surface drives its
+/// own cursor via `wl_pointer.set_cursor` once it starts receiving
+/// `pointer.motion()`/`enter` (already sent above, by the time this runs),
+/// and stomping on that here would fight the client for control of its own
+/// cursor rather than just leaving it alone.
+///
+/// Without this, `cursor_status` was only ever set by client requests --
+/// nothing on the compositor's own side ever asked for a resize cursor at
+/// all, so hovering or dragging one of our own decoration's edges never
+/// looked any different from hovering plain content, regardless of what
+/// shapes `cursor.rs` can actually render.
+///
+/// `over_content` distinguishes "over a client surface that will drive its
+/// own cursor" from "over the bare desktop, where nothing ever will" --
+/// without it, dragging off one of our decoration's resize edges straight
+/// onto empty desktop left `cursor_status` stuck on that resize icon
+/// forever: there is no client there to ever call `set_cursor` and reset
+/// it, and this function's own early-return (for the "let the client drive
+/// it" case) doesn't distinguish an *absent* client from a slow one.
+///
+/// `state.decoration_cursor_active` is what makes the "leave it alone"
+/// branch below safe rather than sticky: reported live as "the resize icon
+/// stays on screen long after the pointer is nowhere near an edge." Moving
+/// from a decoration edge onto plain content sets no new `wl_pointer` focus
+/// (an undecorated/CSD window's edge and its content are the same surface,
+/// just different bands of it - see `hit_test`'s `UNDECORATED_TOP_RESIZE_
+/// MARGIN`), so the client never gets an `enter` event to react to, and most
+/// toolkits only re-call `set_cursor` when *their own* idea of which widget
+/// is hovered changes - which it hasn't, from their point of view, since
+/// they were never told the pointer was ever over a resize edge to begin
+/// with. The resize icon we forced while hovering that edge was therefore
+/// never going to be overwritten by anything, ever, without this: the very
+/// first content tick after leaving a decoration/resize hover resets to the
+/// plain arrow *once*, and only if we're the one who last set it - a
+/// client that has since claimed the cursor itself (tracked by `cursor_
+/// image` in `protocols.rs` clearing this same flag) is left alone on every
+/// following tick, so this can't fight a legitimate client cursor that
+/// isn't changing simply because the pointer kept moving.
+fn update_cursor_shape(state: &mut CompState, hit: Option<(WindowId, TitlebarHit)>, over_layer_surface: bool, over_content: bool) {
+ use smithay::input::pointer::{CursorIcon, CursorImageStatus};
+
+ if over_layer_surface {
+ return;
+ }
+ let edge = match hit {
+ Some((_, TitlebarHit::Resize(edge))) => Some(edge),
+ _ => state.wm.borrow().resize_edge(),
+ };
+ // A live "forcing the cursor here has no visible effect" report earlier
+ // this session turned out to be a real *design* gap, not a rendering
+ // bug: hovering a titlebar button used to fall into the same
+ // `CursorIcon::Default` branch as the plain drag area below it --
+ // indistinguishable from "not hovering anything special" (the desktop's
+ // own baseline cursor is also `Default`), so there was never any visible
+ // change to notice in the first place. `Pointer` (a real hand/finger
+ // cursor - see `cursor.rs`'s own `pointer_bitmap`) is what every
+ // mainstream desktop shows over a clickable titlebar button instead.
+ let icon = match edge {
+ Some(edge) => resize_cursor_icon(edge),
+ // A real button (Close/Maximize/Minimize), not the plain drag
+ // area - a hand cursor, matching every mainstream desktop's own
+ // convention for a clickable titlebar control.
+ None if matches!(hit, Some((_, TitlebarHit::Close | TitlebarHit::Maximize | TitlebarHit::Minimize))) => CursorIcon::Pointer,
+ // Hovering our own decoration but not an edge or a button (the
+ // drag area) and not actively resizing: back to the plain arrow.
+ None if hit.is_some() => CursorIcon::Default,
+ // Over a client's own content: leave `cursor_status` alone once the
+ // client has claimed it - but if we're still showing whatever we
+ // last forced (a resize icon from the edge just left), reset it
+ // back to the plain arrow this one time rather than leaving it
+ // stuck, since nothing else is ever going to.
+ None if over_content => {
+ if state.decoration_cursor_active {
+ state.cursor_status = CursorImageStatus::Named(CursorIcon::Default);
+ state.decoration_cursor_active = false;
+ }
+ return;
+ }
+ // Bare desktop: nothing else will ever reset this, so we have to.
+ None => CursorIcon::Default,
+ };
+ state.cursor_status = CursorImageStatus::Named(icon);
+ state.decoration_cursor_active = true;
+}
+
+fn resize_cursor_icon(edge: srdwm_core::ResizeEdge) -> smithay::input::pointer::CursorIcon {
+ use smithay::input::pointer::CursorIcon;
+ use srdwm_core::ResizeEdge;
+ match edge {
+ ResizeEdge::Left | ResizeEdge::Right => CursorIcon::EwResize,
+ ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::NsResize,
+ ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize,
+ ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize,
+ }
+}
+
+pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logical>, button: u32, pressed: bool, time: u32) {
+ notify_idle_activity(state);
+ const BTN_LEFT: u32 = 0x110;
+ const BTN_RIGHT: u32 = 0x111;
+ const BTN_MIDDLE: u32 = 0x112;
+ let serial = SERIAL_COUNTER.next_serial();
+
+ // Locked: forward the click to the lock surface (it may have a button or
+ // a text field) but never let it focus, raise, drag, or close a window.
+ if state.lock.locked {
+ if let Some(pointer) = state.seat.get_pointer() {
+ let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
+ pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
+ pointer.frame(state);
+ }
+ return;
+ }
+
+ // The context menu, if open, captures every press: a click inside
+ // resolves whichever row it landed on, a click anywhere else just
+ // dismisses it. Neither case falls through to the normal handling
+ // below - opening the menu and then clicking a window underneath it
+ // should not *also* focus/raise/drag that window on the same click,
+ // the same "one click, one action" rule every native window menu
+ // follows.
+ if pressed {
+ if let Some(menu) = state.context_menu.take() {
+ if let Some(row) = menu.row_at(pos.x as i32, pos.y as i32) {
+ let (_, action) = menu.items[row];
+ state.close_context_menu();
+ state.run_context_menu_action(menu.window, action);
+ } else {
+ state.close_context_menu();
+ }
+ return;
+ }
+ // Same "one click, one action" rule as the context menu above --
+ // a click inside the Snap-Layouts flyout applies that zone, a click
+ // anywhere else just dismisses it.
+ if let Some(flyout) = state.snap_flyout.take() {
+ if let Some(zone) = flyout.zone_at(pos.x as i32, pos.y as i32) {
+ state.close_snap_flyout();
+ state.run_snap_flyout_action(flyout.window, zone);
+ } else {
+ state.close_snap_flyout();
+ }
+ return;
+ }
+ }
+
+ // Modifier+drag: with the modifier held, dragging *anywhere* in a window
+ // moves it (left button) or resizes it from the nearest corner (right
+ // button) - the `bindm SUPER, mouse:272/273` gesture. Without this a
+ // window can only be moved by its titlebar, which is useless for
+ // windows that have none (fullscreen, CSD apps, layer surfaces).
+ //
+ // Checked before the titlebar hit-test so the modifier wins over the
+ // decoration: holding the modifier and grabbing the titlebar should
+ // still move, not press a titlebar button.
+ if pressed && (button == BTN_LEFT || button == BTN_RIGHT) {
+ let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state()));
+ if mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) {
+ let target = state.wm.borrow().window_at(pos.x as i32, pos.y as i32);
+ if let Some(id) = target {
+ focus_window(state, id);
+ let mut wm = state.wm.borrow_mut();
+ if button == BTN_LEFT {
+ wm.start_drag(id, pos.x as i32, pos.y as i32);
+ } else {
+ let edge = wm.nearest_corner(id, pos.x as i32, pos.y as i32);
+ wm.start_resize(id, edge, pos.x as i32, pos.y as i32);
+ }
+ return;
+ }
+ }
+ }
+
+ if pressed && button == BTN_LEFT {
+ let layer_hit = layer_surface_under(state, pos);
+ if let Some((surface, _)) = &layer_hit {
+ // Look the surface up on whichever output actually holds it.
+ let on_demand = state
+ .outputs()
+ .find_map(|output| {
+ layer_map_for_output(output)
+ .layer_for_surface(surface, WindowSurfaceType::ALL)
+ .map(|l| {
+ l.can_receive_keyboard_focus()
+ && l.cached_state().keyboard_interactivity != KeyboardInteractivity::Exclusive
+ })
+ })
+ .unwrap_or(false);
+ // `Exclusive` layers (lock screens, exclusive launchers) already
+ // hold focus from `ensure_layer_initial_configure` and keep it
+ // regardless of where else is clicked; only `OnDemand` layers
+ // (e.g. a bar's search field) claim it on click.
+ if on_demand {
+ state.set_keyboard_focus(Some(surface.clone()));
+ }
+ }
+ let hit = if layer_hit.is_some() { None } else { hit_test_animated(state, pos.x as i32, pos.y as i32) };
+ if let Some((id, hit)) = hit {
+ focus_window(state, id);
+ match hit {
+ TitlebarHit::Drag => {
+ // Double-click the titlebar to maximise, as every other
+ // desktop does - one of the few window operations that
+ // otherwise needs the keyboard or a precise button hit.
+ if state.is_double_click(id, time) {
+ state.wm.borrow_mut().toggle_maximize(id);
+ state.sync_geometry(id);
+ crate::foreign_toplevel::send_state(state, id);
+ } else {
+ state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32)
+ }
+ }
+ TitlebarHit::Close => {
+ if let Some(w) = state.id_to_window.get(&id) {
+ close_dwindow(w);
+ }
+ }
+ TitlebarHit::Maximize => {
+ state.wm.borrow_mut().toggle_maximize(id);
+ state.sync_geometry(id);
+ crate::foreign_toplevel::send_state(state, id);
+ }
+ TitlebarHit::Minimize => {
+ state.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(state, id);
+ }
+ TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32),
+ }
+ } else if layer_hit.is_none() {
+ if let Some((window, _loc)) = state.space.element_under(pos).filter(|(w, _)| dwindow_is_visible(state, w)) {
+ let window = window.clone();
+ // `focus_window` itself raises both `Space` and pinned
+ // windows now - see its own doc comment. No longer done
+ // manually here first.
+ if let Some(&id) = dwindow_wl_surface(&window).and_then(|s| state.surface_to_id.get(&s)) {
+ focus_window(state, id);
+ }
+ }
+ }
+ } else if pressed && (button == BTN_RIGHT || button == BTN_MIDDLE) {
+ // Right-click a titlebar: open the window menu (minimize/maximize/
+ // pin/close) - previously nothing at all, since the only
+ // right-button behaviour anywhere was the SUPER+right-drag resize
+ // gesture above, which needs the modifier held. Middle-click:
+ // lower the window instead, the convention several X11 WMs
+ // (twm, fvwm, IceWM) have always had. Both only fire on the
+ // titlebar's plain drag area - a resize edge or one of the three
+ // buttons keeps its own single meaning regardless of which button
+ // was pressed, so a right-click on the close button, say, doesn't
+ // do something else entirely.
+ let hit = hit_test_animated(state, pos.x as i32, pos.y as i32);
+ match (button, hit) {
+ (BTN_RIGHT, Some((id, TitlebarHit::Drag))) => state.open_context_menu(id, (pos.x as i32, pos.y as i32)),
+ (BTN_MIDDLE, Some((id, TitlebarHit::Drag))) => state.wm.borrow_mut().lower_window(id),
+ // Right-click the maximize button itself: the Snap-Layouts
+ // flyout (pick a half/quarter position for this window)
+ // instead of the window menu - a plain left-click there still
+ // just toggles maximize, unchanged.
+ (BTN_RIGHT, Some((id, TitlebarHit::Maximize))) => state.open_snap_flyout(id, (pos.x as i32, pos.y as i32)),
+ _ => {}
+ }
+ } else if !pressed {
+ let mut wm = state.wm.borrow_mut();
+ let was_dragging = wm.is_dragging();
+ let was_resizing = wm.is_resizing();
+ // `start_drag`/`start_resize` both focus the window they grab, and
+ // nothing else can change focus while a grab is active (the pointer
+ // is captured by the drag, not routed elsewhere) - so `focused_id`
+ // is reliably the window `end_drag`/`end_resize` are about to
+ // finish, without `WindowManager` needing to hand the id back
+ // itself.
+ let id = wm.focused_id();
+ if was_dragging {
+ wm.end_drag();
+ } else if was_resizing {
+ wm.end_resize();
+ }
+ drop(wm);
+ // `end_drag` can snap the geometry one more time (edge/top-of-
+ // screen snapping, `SmartPlacement::snap_zone`) *after* the last
+ // `update_drag` already moved the window - without this, that
+ // final snap only ever reached `Window.geometry`. The border and
+ // titlebar redraw fresh from live geometry every frame, so they'd
+ // jump to the snapped rect immediately, while the client's actual
+ // mapped surface (driven only by `sync_geometry`'s
+ // `space.map_element`/`xdg_toplevel.configure`) stayed wherever the
+ // drag physically stopped - decoration visibly detached from its
+ // own window's content. Click routing desynced the same way:
+ // `hit_test`/`window_at` read the now-snapped `Window.geometry`
+ // while `space.element_under` still read the stale pre-snap
+ // position, so clicks in the visually-snapped zone resolved
+ // against the wrong rect. The X11 backend already gets this right
+ // (`crates/x11/src/lib.rs`'s `ButtonRelease` handler); this was the
+ // one call site in the module doc'd as "shared by both backends"
+ // that never got the same fix.
+ if was_dragging || was_resizing {
+ if let Some(id) = id {
+ state.sync_geometry(id);
+ }
+ }
+ }
+
+ // Re-assert real Wayland pointer focus at `pos` immediately before the
+ // actual click - see `refresh_pointer_focus`'s own doc comment for why
+ // this can't just trust whatever the last motion event left focus at.
+ // A no-op from the client's perspective when focus was already correct
+ // (an idempotent motion event at the same surface-local coordinates it
+ // already has), so this costs nothing in the common case.
+ //
+ // Also where `pointer_button_grab` starts and ends - see its own doc
+ // comment. Only the 0->1 transition captures a new grab target (a
+ // second button going down mid-gesture keeps whatever the first press
+ // already locked in); only the ->0 transition releases it, and not
+ // before this press/release's own `pointer.button()` below still goes
+ // out under the (still-active) grab.
+ let (.., resolved) = refresh_pointer_focus(state, pos, time);
+ if pressed {
+ if state.pointer_buttons_held == 0 {
+ state.pointer_button_grab = resolved;
+ }
+ state.pointer_buttons_held += 1;
+ } else {
+ state.pointer_buttons_held = state.pointer_buttons_held.saturating_sub(1);
+ }
+ if let Some(pointer) = state.seat.get_pointer() {
+ let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
+ pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
+ // See the matching comment in `handle_pointer_position`: `button`
+ // alone never tells the client the event is ready to act on, only
+ // `frame` does.
+ pointer.frame(state);
+ }
+ if !pressed && state.pointer_buttons_held == 0 {
+ state.pointer_button_grab = None;
+ }
+}
diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs
index 70e5c57..90660d1 100644
--- a/crates/wayland/src/lib.rs
+++ b/crates/wayland/src/lib.rs
@@ -33,11 +33,16 @@
//! client; everything else is forwarded, mirroring X11's grab-specific-keys
//! behavior instead of the coarser "any Super-held key is ours" heuristic
//! an earlier pass used.
-//! - xdg-decoration is forced to server-side mode (`Mode::ServerSide`) so
-//! well-behaved clients don't also draw their own client-side titlebar.
+//! - xdg-decoration offers server-side mode by default (`theme.
+//! default_decorated`/`srd set decoration_mode`), but a client that
+//! explicitly requests client-side is honored rather than overridden --
+//! see `XdgDecorationHandler::request_mode` in `protocols.rs` for why
+//! forcing server-side unconditionally used to give some clients (Firefox,
+//! concretely) two overlapping sets of window buttons.
mod appmenu;
mod blur;
+mod color_filter;
mod context_menu;
mod snap_flyout;
mod cursor;
@@ -49,6 +54,7 @@ mod gtk_shell;
mod gtk_shell_protocol;
mod input;
mod lock;
+mod monitor_layout;
mod native_lock;
mod output_management;
mod output_power;
diff --git a/crates/wayland/src/monitor_layout.rs b/crates/wayland/src/monitor_layout.rs
new file mode 100644
index 0000000..775eb05
--- /dev/null
+++ b/crates/wayland/src/monitor_layout.rs
@@ -0,0 +1,153 @@
+//! Persists and restores monitor layout (position, enabled state) across
+//! restarts - srdwm's own responsibility, not any particular panel's.
+//!
+//! Before this, whatever arranged outputs on a restart was whichever panel
+//! happened to be running: it read back its own remembered layout from its
+//! own config store, seconds after srdwm itself had already brought every
+//! head up at some arbitrary default position, and dispatched `srd
+//! dispatch set output position` for each one to fix it up after the fact.
+//! That has three real problems, not just one: the user watches the wrong
+//! arrangement for however long the panel takes to start and apply it (one
+//! peer session measured 13.7s on its own, worse on a cold boot before the
+//! panel is even launched); the layout is never restored at all if that
+//! panel doesn't run or loses its own store; and this compositor is meant
+//! to work with any panel or none, not assume one particular shell exists
+//! to do this job.
+//!
+//! Applied once, at startup, before the Wayland socket is even bound (see
+//! `UdevPlatform::connect`'s call site) - no client, panel or otherwise,
+//! can possibly see a pre-restore arrangement, not even for one frame.
+//! Saved on every live position/enabled change (`apply_output_position`,
+//! `disable_connector_by_name`, `enable_connector_by_name`), so a panel's
+//! own output-management UI (or `srd dispatch set output ...` run by hand)
+//! keeps working exactly as before and this file just stays the one place
+//! that remembers the result.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
+pub(crate) struct PersistedOutput {
+ /// Physical pixels - this compositor's own convention throughout, the
+ /// same space `srd monitors`/`apply_output_position` already use.
+ pub(crate) x: i32,
+ pub(crate) y: i32,
+ pub(crate) enabled: bool,
+}
+
+#[derive(Serialize, Deserialize, Default)]
+struct PersistedLayout {
+ /// Keyed by connector name (`eDP-1`, `HDMI-A-1`, ...) - the same
+ /// identifier every other per-monitor config in this codebase
+ /// (`srd.monitor.scale`, `srd dispatch set output ...`) already keys
+ /// off, and the one thing guaranteed stable across a reboot that a
+ /// kernel-assigned head index or CRTC handle is not.
+ outputs: HashMap<String, PersistedOutput>,
+}
+
+/// `$XDG_STATE_HOME/srd`, else `~/.local/state/srd` - state, not config:
+/// this file records what the compositor *did*, not something the user
+/// hand-edits, the same distinction XDG draws between the two directories.
+/// Mirrors `srdwm/src/main.rs`'s own `config_dir()` shape (`$SRDWM_*`
+/// override first, then the XDG var, then the hardcoded fallback) without
+/// sharing code with it - this is `srdwm-wayland`, that's the `srdwm`
+/// binary crate, and duplicating four lines beats a cross-crate dependency
+/// for it.
+fn state_dir() -> PathBuf {
+ if let Ok(p) = std::env::var("SRDWM_STATE_PATH") {
+ return PathBuf::from(p);
+ }
+ if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
+ return PathBuf::from(xdg).join("srd");
+ }
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home).join(".local/state/srd");
+ }
+ PathBuf::from("state/srd")
+}
+
+fn layout_path() -> PathBuf {
+ state_dir().join("monitor-layout.json")
+}
+
+/// Every remembered output, by connector name. Empty (not an error) if the
+/// file doesn't exist yet - the ordinary case on a machine's first run,
+/// or the first run after this feature shipped - or if it's present but
+/// unreadable/corrupt, since a bad state file should degrade to "use the
+/// default layout", not stop the compositor from starting at all.
+pub(crate) fn load() -> HashMap<String, PersistedOutput> {
+ let path = layout_path();
+ let Ok(bytes) = std::fs::read(&path) else { return HashMap::new() };
+ match serde_json::from_slice::<PersistedLayout>(&bytes) {
+ Ok(layout) => layout.outputs,
+ Err(e) => {
+ log::warn!("monitor_layout: couldn't parse {path:?} ({e}); starting with the default layout instead");
+ HashMap::new()
+ }
+ }
+}
+
+/// Overwrites one connector's remembered position/enabled state and
+/// rewrites the whole file. Read-modify-write, not an in-memory cache kept
+/// across calls - position/enabled changes are rare (a user rearranging
+/// monitors, not a per-frame event), so re-reading the small file each
+/// time costs nothing and needs no separate cache-invalidation story.
+pub(crate) fn save_output(name: &str, entry: PersistedOutput) {
+ let mut layout = PersistedLayout { outputs: load() };
+ layout.outputs.insert(name.to_string(), entry);
+ let dir = state_dir();
+ if let Err(e) = std::fs::create_dir_all(&dir) {
+ log::warn!("monitor_layout: couldn't create {dir:?} ({e}); this layout change won't survive a restart");
+ return;
+ }
+ let Ok(bytes) = serde_json::to_vec_pretty(&layout) else { return };
+ let path = layout_path();
+ // Written to a `.tmp` sibling and renamed into place - same reasoning
+ // as `udev/capture.rs`'s `write_ppm`: a crash or a second srdwm
+ // instance racing this write must never leave a half-written, corrupt
+ // file behind for the next startup's `load()` to choke on.
+ let tmp = path.with_extension("json.tmp");
+ if let Err(e) = std::fs::write(&tmp, &bytes) {
+ log::warn!("monitor_layout: couldn't write {tmp:?} ({e}); this layout change won't survive a restart");
+ return;
+ }
+ if let Err(e) = std::fs::rename(&tmp, &path) {
+ log::warn!("monitor_layout: couldn't rename {tmp:?} to {path:?} ({e}); this layout change won't survive a restart");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Only the pure JSON round-trip is exercised here - `state_dir()`/
+ // `load()`/`save_output()` all touch real environment variables and
+ // the filesystem, which parallel `cargo test` execution can't safely
+ // share (a per-test `SRDWM_STATE_PATH` override would race every other
+ // test in this binary reading the same process-global env var), the
+ // same reasoning `config_dir()` in `srdwm/src/main.rs` already has no
+ // test coverage for.
+ #[test]
+ fn a_persisted_layout_survives_a_json_round_trip() {
+ let mut outputs = HashMap::new();
+ outputs.insert("eDP-1".to_string(), PersistedOutput { x: 0, y: 0, enabled: true });
+ outputs.insert("HDMI-A-1".to_string(), PersistedOutput { x: -1920, y: 0, enabled: false });
+ let layout = PersistedLayout { outputs };
+ let bytes = serde_json::to_vec(&layout).unwrap();
+ let parsed: PersistedLayout = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(parsed.outputs.get("eDP-1"), Some(&PersistedOutput { x: 0, y: 0, enabled: true }));
+ assert_eq!(parsed.outputs.get("HDMI-A-1"), Some(&PersistedOutput { x: -1920, y: 0, enabled: false }));
+ }
+
+ #[test]
+ fn corrupt_json_falls_back_to_an_empty_layout_not_an_error() {
+ let result = serde_json::from_slice::<PersistedLayout>(b"not valid json");
+ assert!(result.is_err(), "sanity: this fixture must actually fail to parse");
+ // `load()` itself can't be called here (touches the real
+ // filesystem/env) - this locks in the *shape* of the fallback
+ // `load()` relies on: a parse error, not a panic, is what lets it
+ // degrade to `HashMap::new()` instead of taking the compositor down.
+ }
+}
diff --git a/crates/wayland/src/native_lock.rs b/crates/wayland/src/native_lock.rs
index ef59ba6..31c4fc3 100644
--- a/crates/wayland/src/native_lock.rs
+++ b/crates/wayland/src/native_lock.rs
@@ -357,7 +357,7 @@ where
/// `blit_glyph`/`rgb_to_bgra`), promoted to `pub(crate)` there rather than
/// duplicated here.
fn render_ui_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8>, (i32, i32)) {
- use crate::decoration::{blit_glyph, find_system_font, rgb_to_bgra, FONT_PIXELS, TEXT_LEFT_PADDING};
+ use crate::decoration::{blit_glyph, find_system_font, rgb_to_bgra, round_bottom_corners, round_top_corners, FONT_PIXELS, TEXT_LEFT_PADDING};
const WIDTH: usize = 360;
const HEIGHT: usize = 170;
@@ -408,18 +408,34 @@ fn render_ui_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8
// Border, drawn last so it isn't overdrawn by any fill above --
// same convention `render_context_menu`/`render_snap_flyout` use.
+ // 2px, matching `ThemeConfig::default_border_width` - a 1px line at
+ // this box's size read as a thin, easy-to-miss hairline rather than a
+ // deliberate frame around the box.
+ const BORDER: usize = 2;
let border_px = rgb_to_bgra(theme.box_border, 255);
- for x in 0..WIDTH {
- buf[x * 4..x * 4 + 4].copy_from_slice(&border_px);
- let last_row = (HEIGHT - 1) * WIDTH + x;
- buf[last_row * 4..last_row * 4 + 4].copy_from_slice(&border_px);
- }
- for y in 0..HEIGHT {
- let left = y * WIDTH;
- buf[left * 4..left * 4 + 4].copy_from_slice(&border_px);
- let right = y * WIDTH + WIDTH - 1;
- buf[right * 4..right * 4 + 4].copy_from_slice(&border_px);
+ for t in 0..BORDER {
+ for x in 0..WIDTH {
+ buf[(t * WIDTH + x) * 4..(t * WIDTH + x) * 4 + 4].copy_from_slice(&border_px);
+ let row = (HEIGHT - 1 - t) * WIDTH + x;
+ buf[row * 4..row * 4 + 4].copy_from_slice(&border_px);
+ }
+ for y in 0..HEIGHT {
+ let left = y * WIDTH + t;
+ buf[left * 4..left * 4 + 4].copy_from_slice(&border_px);
+ let right = y * WIDTH + WIDTH - 1 - t;
+ buf[right * 4..right * 4 + 4].copy_from_slice(&border_px);
+ }
}
+ // Rounded, like every other srdwm-drawn surface (titlebar, window
+ // border) - `LockConfig::corner_radius` existed as a config field
+ // (default 10) already, but nothing here ever actually read it, so the
+ // lock box always rendered as a hard flat rectangle regardless of its
+ // value. Clipping after the border fill above means the corner pixels
+ // of that border get cut along with the background, the same "cut,
+ // don't stroke" treatment `render_titlebar`'s own corners get.
+ round_top_corners(&mut buf, WIDTH, HEIGHT, theme.corner_radius, theme.corner_radius as i32);
+ round_bottom_corners(&mut buf, WIDTH, HEIGHT, theme.corner_radius);
+
(buf, (WIDTH as i32, HEIGHT as i32))
}
diff --git a/crates/wayland/src/output_management.rs b/crates/wayland/src/output_management.rs
index 05040b9..2e2ae85 100644
--- a/crates/wayland/src/output_management.rs
+++ b/crates/wayland/src/output_management.rs
@@ -309,7 +309,14 @@ fn apply_or_test(state: &mut CompState, config: &ZwlrOutputConfigurationV1, data
output.change_current_state(None, None, Some(Scale::Fractional(*s)), None);
}
if let Some((x, y)) = position {
- apply_output_position(state, &output, Point::from((*x, *y)));
+ // A real `wlr-output-management-v1` client's own position
+ // request is logical, by protocol convention - `apply_
+ // output_position` wants physical (see its own doc
+ // comment), so it gets converted here rather than assumed.
+ let output_scale = output.current_scale().fractional_scale();
+ let physical: Point<i32, smithay::utils::Logical> =
+ (((*x as f64) * output_scale).round() as i32, ((*y as f64) * output_scale).round() as i32).into();
+ apply_output_position(state, &output, physical);
}
}
}
@@ -327,16 +334,48 @@ fn apply_or_test(state: &mut CompState, config: &ZwlrOutputConfigurationV1, data
/// (used to translate render geometry into head-local space) each keep
/// their own copy for reasons documented on their own fields, and would
/// otherwise silently drift from what `Output` now reports.
-pub(crate) fn apply_output_position(state: &mut CompState, output: &Output, new_location: Point<i32, smithay::utils::Logical>) {
- output.change_current_state(None, None, None, Some(new_location));
+///
+/// `new_location` is *physical* pixels - the same space `Platform::
+/// monitors()` reports `full_x`/`full_y` in (see that function's own doc
+/// comment for why), which is what `entry.location`/`head.location` are
+/// everywhere else in this compositor (render geometry, damage tracking,
+/// cursor clamping). `output.change_current_state`'s own position
+/// parameter is a real Wayland-protocol value, and `wl_output`/
+/// `xdg_output` report position to clients in *logical* points - always,
+/// not a choice this compositor makes - so it gets a separately scaled
+/// copy here rather than the raw physical value. Storing the unconverted
+/// physical value in `change_current_state` too (what this used to do)
+/// looked harmless locally but told every real Wayland client the wrong
+/// logical position for any output whose scale isn't exactly `1.0`,
+/// reported from the AGS peer session as a dead gap between two monitors'
+/// desktop space wide enough to drop a window or a pointer into: their
+/// own arrangement math chains outputs by the physical width `srd
+/// monitors` reports, correctly, but a `1.25`-scale output being told a
+/// `1920`-logical-point position when its real logical width was `1536`
+/// opened exactly that gap.
+///
+/// Callers already holding a *logical* position (a real `wlr-output-
+/// management-v1` client's own request, which is logical by protocol
+/// convention) must convert to physical before calling this - see this
+/// function's own call site in `handle_apply_or_test` for that
+/// conversion.
+pub(crate) fn apply_output_position(state: &mut CompState, output: &Output, new_location_physical: Point<i32, smithay::utils::Logical>) {
+ let scale = output.current_scale().fractional_scale();
+ let logical: Point<i32, smithay::utils::Logical> =
+ ((new_location_physical.x as f64 / scale).round() as i32, (new_location_physical.y as f64 / scale).round() as i32).into();
+ output.change_current_state(None, None, None, Some(logical));
if let Some(entry) = state.outputs.iter_mut().find(|e| &e.output == output) {
- entry.location = new_location;
+ entry.location = new_location_physical;
}
if let Some(udev) = state.udev.as_mut() {
if let Some(head) = udev.heads.iter_mut().find(|h| &h.output == output) {
- head.location = new_location;
+ head.location = new_location_physical;
}
}
+ // Remembered for next startup - see `monitor_layout`'s own module doc
+ // comment for why this compositor persists its own layout rather than
+ // leaving that to whichever panel happens to be running.
+ crate::monitor_layout::save_output(&output.name(), crate::monitor_layout::PersistedOutput { x: new_location_physical.x, y: new_location_physical.y, enabled: true });
}
fn announce_head(state: &mut CompState, manager: &ZwlrOutputManagerV1, output: &Output, client: &Client, dh: &DisplayHandle) {
diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs
index 391f665..664e889 100644
--- a/crates/wayland/src/protocols.rs
+++ b/crates/wayland/src/protocols.rs
@@ -6,806 +6,34 @@
//! hold no logic of their own beyond what the protocol itself dictates. The
//! session-lock handler is the one exception, living in [`crate::lock`]
//! alongside the rest of that feature.
+//!
+//! Split one file per protocol handler, matching niri's own convention (see
+//! docs/TODO.md's "module splits" entry) - [`buffer`] groups `ShmHandler`/
+//! `BufferHandler`/`DmabufHandler` together since none has more than a
+//! handful of lines, and [`misc`] groups the three purely-default-impl stub
+//! handlers (`OutputHandler`/`TabletSeatHandler`/`FractionalScaleHandler`)
+//! for the same reason; every other module is exactly one handler.
+
+mod buffer;
+mod compositor;
+mod idle;
+mod input_method;
+mod layer_shell;
+mod misc;
+mod seat;
+mod selection;
+mod xdg_activation;
+mod xdg_decoration;
+mod xdg_shell;
-use smithay::desktop::{find_popup_root_surface, layer_map_for_output, LayerSurface as DesktopLayerSurface, PopupKeyboardGrab, PopupKind, PopupPointerGrab};
-use smithay::input::pointer::{CursorImageStatus, Focus};
-use smithay::input::{Seat, SeatHandler, SeatState};
-use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode;
-use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
-use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
-use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
-use smithay::reexports::wayland_server::protocol::wl_seat;
-use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
-use smithay::reexports::wayland_server::Client;
-use smithay::reexports::wayland_server::Resource;
-use smithay::backend::allocator::dmabuf::Dmabuf;
-use smithay::backend::renderer::ImportDma;
-use smithay::utils::Serial;
-use smithay::wayland::buffer::BufferHandler;
-use smithay::wayland::compositor::{CompositorClientState, CompositorHandler, CompositorState};
-use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
-use smithay::wayland::xdg_activation::{XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData};
-use smithay::wayland::input_method::PopupSurface as ImePopupSurface;
-use smithay::wayland::selection::data_device::{
- ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler,
-};
-use smithay::wayland::selection::primary_selection::{PrimarySelectionHandler, PrimarySelectionState};
-use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState};
-use smithay::wayland::compositor::{add_pre_commit_hook, with_states};
-use smithay::wayland::selection::SelectionHandler;
-use smithay::wayland::shell::wlr_layer::{
- Layer, LayerSurface as WlrLayerSurface, LayerSurfaceCachedState, WlrLayerShellHandler, WlrLayerShellState,
-};
-use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
-use smithay::wayland::shell::xdg::{PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState};
-use smithay::wayland::shm::{ShmHandler, ShmState};
-use smithay::wayland::tablet_manager::TabletSeatHandler;
use smithay::{
delegate_compositor, delegate_cursor_shape, delegate_data_control, delegate_data_device, delegate_dmabuf,
- delegate_layer_shell, delegate_output, delegate_primary_selection, delegate_seat, delegate_session_lock,
- delegate_shm, delegate_xdg_activation, delegate_xdg_decoration, delegate_xdg_shell,
- delegate_input_method_manager, delegate_text_input_manager,
+ delegate_input_method_manager, delegate_layer_shell, delegate_output, delegate_primary_selection, delegate_seat,
+ delegate_session_lock, delegate_shm, delegate_text_input_manager, delegate_virtual_keyboard_manager,
+ delegate_xdg_activation, delegate_xdg_decoration, delegate_xdg_shell,
};
-use crate::state::{ClientState, CompState};
-
-impl CompositorHandler for CompState {
- fn compositor_state(&mut self) -> &mut CompositorState {
- &mut self.compositor_state
- }
-
- fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState {
- // Two possible client kinds now: our own `ClientState` for regular
- // Wayland clients, or smithay's `XWaylandClientData` for the single
- // XWayland client (see `xwayland.rs`) - both carry a
- // `CompositorClientState`, just under different wrapper types.
- if let Some(state) = client.get_data::<ClientState>() {
- return &state.compositor_state;
- }
- &client.get_data::<smithay::xwayland::XWaylandClientData>().expect("client is neither ours nor XWayland's").compositor_state
- }
-
- /// Workaround for a real smithay bug (see docs/PANEL_SUPPORT_TODO.md and
- /// `layer_destroyed` below): destroying a `zwlr_layer_surface_v1` role
- /// resets the surface's `LayerSurfaceCachedState` to
- /// `Default::default()` (size 0x0, no anchor) rather than removing it,
- /// but the pre-commit hook smithay itself registers at
- /// `get_layer_surface` time keeps validating that state against every
- /// future commit regardless of whether the role still exists --
- /// tripping its own `width/height 0 requested without ... anchors`
- /// check and posting `invalid_size`, which kills the client's whole
- /// connection over what is protocol-legal (committing a now-roleless
- /// surface).
- ///
- /// Fixed by registering our own pre-commit hook here, in `new_surface`
- /// - called at `wl_compositor.create_surface`, strictly before any
- /// later `get_layer_surface` on the same surface could register
- /// smithay's own hook. Hooks run in registration order (`tree.rs`:
- /// `pre_commit_hooks` is a plain `Vec`, pushed and iterated in order),
- /// so ours always runs first and can neutralize the stale reset state
- /// before smithay's hook ever inspects it. This depends on that
- /// ordering guarantee holding in future smithay versions - it isn't
- /// documented as an API contract, just an implementation detail
- /// confirmed against 0.7.0's source - so re-check this file against
- /// whatever smithay version replaces it.
- ///
- /// Cost: one closure registered per `wl_surface` (not just layer
- /// surfaces, since we don't know in advance which ones will become
- /// one), each a no-op unless that exact surface is in
- /// `dead_layer_surfaces`.
- fn new_surface(&mut self, surface: &WlSurface) {
- add_pre_commit_hook::<CompState, _>(surface, |state, _dh, surface| {
- if !state.dead_layer_surfaces.contains(surface) {
- return;
- }
- with_states(surface, |states| {
- let mut cached = states.cached_state.get::<LayerSurfaceCachedState>();
- let pending = cached.pending();
- if pending.size.w == 0 && !pending.anchor.anchored_horizontally() {
- pending.size.w = 1;
- }
- if pending.size.h == 0 && !pending.anchor.anchored_vertically() {
- pending.size.h = 1;
- }
- });
- });
- }
-
- fn commit(&mut self, surface: &WlSurface) {
- smithay::backend::renderer::utils::on_commit_buffer_handler::<CompState>(surface);
- // XWayland's association of an X11 window with this wl_surface can
- // arrive at any point relative to the map request (see
- // `xwayland.rs`'s module docs); `surface_associated` handles the
- // common ordering, this retries the surfaces still waiting on a
- // commit to actually make that association queryable.
- self.retry_pending_x11_windows();
- if let Some(&id) = self.surface_to_id.get(surface) {
- if let Some(w) = self.id_to_window.get(&id) {
- w.on_commit();
- }
- // See `content_epoch`'s doc comment: this is the only per-commit
- // signal the udev backend's rounded-corner mask cache has to
- // invalidate itself, since content can change every frame,
- // independent of the geometry-driven points `redraw_decoration_
- // buffer` already runs at.
- *self.content_epoch.entry(id).or_insert(0) += 1;
- crate::state::sync_toplevel_metadata(self, id, surface);
- }
- // Before `ensure_layer_initial_configure`: if this commit just
- // hid or re-showed a layer surface, `sync_layer_visibility` needs
- // to unmap/re-map it first, so the lookup that function does via
- // `layer_for_surface` sees the corrected state rather than acting
- // on stale membership in `LayerMap`'s own list.
- self.sync_layer_visibility(surface);
- self.ensure_layer_initial_configure(surface);
- // Advances a just-created popup from unmapped to mapped (needed for
- // `PopupManager::popups_for_surface`, which `popup_render_elements`
- // reads at render time) and prunes dead ones. Cheap and only does
- // real work on a popup-role surface, so doing it on every commit
- // rather than throttling is not worth the extra bookkeeping.
- self.popups.commit(surface);
- self.popups.cleanup();
- }
-}
-
-impl XdgShellHandler for CompState {
- fn xdg_shell_state(&mut self) -> &mut XdgShellState {
- &mut self.xdg_shell_state
- }
-
- fn new_toplevel(&mut self, surface: ToplevelSurface) {
- self.new_managed_window(surface);
- }
-
- /// `move_request`/`resize_request` were also still smithay's default
- /// no-op implementations - a much larger gap than the five below:
- /// this is *how a client-side-decorated window gets dragged or resized
- /// by its own titlebar/edges at all*. A window we draw our own
- /// decoration for never needed this (`TitlebarHit::Drag`/`Resize` in
- /// `input.rs` detect the click directly, since we own those pixels),
- /// but a window that negotiated client-side decoration and draws its
- /// own titlebar - Firefox, and most GTK4 apps by default - handles
- /// the click itself and then asks the compositor to actually perform
- /// the move/resize via exactly these two requests. Left unimplemented,
- /// dragging or resizing any such window by its own chrome did
- /// nothing at all - the only way to reposition it was the
- /// modifier+drag-anywhere gesture (`bindm`), which most users have no
- /// reason to know exists and doesn't work for resize-from-a-specific-
- /// edge at all. Reuses the exact same `WindowManager::start_drag`/
- /// `start_resize` the pointer-driven titlebar handlers call --
- /// `handle_pointer_position`/`handle_pointer_button` already drive any
- /// in-progress drag/resize to completion on subsequent motion/release
- /// regardless of what started it, so no smithay pointer grab is
- /// needed here at all, just the same start call from a different
- /// trigger.
- fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) {
- // Temporary: added to trace a live report that dragging a CSD
- // window (Firefox) by its own tab strip/header bar does nothing --
- // this is the only way to tell "the client never sent xdg_toplevel
- // ::move at all" apart from "it sent it and something downstream
- // of here didn't follow through." Remove once that's settled.
- match self.surface_to_id.get(surface.wl_surface()) {
- Some(&id) => {
- let pos = crate::input::last_pointer_pos(self);
- log::info!("move_request: window {id:?} at pointer {pos:?}");
- self.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32);
- }
- None => log::warn!("move_request: surface has no tracked window id"),
- }
- }
-
- fn resize_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial, edges: xdg_toplevel::ResizeEdge) {
- let Some(edge) = (match edges {
- xdg_toplevel::ResizeEdge::Top => Some(srdwm_core::ResizeEdge::Top),
- xdg_toplevel::ResizeEdge::Bottom => Some(srdwm_core::ResizeEdge::Bottom),
- xdg_toplevel::ResizeEdge::Left => Some(srdwm_core::ResizeEdge::Left),
- xdg_toplevel::ResizeEdge::Right => Some(srdwm_core::ResizeEdge::Right),
- xdg_toplevel::ResizeEdge::TopLeft => Some(srdwm_core::ResizeEdge::TopLeft),
- xdg_toplevel::ResizeEdge::TopRight => Some(srdwm_core::ResizeEdge::TopRight),
- xdg_toplevel::ResizeEdge::BottomLeft => Some(srdwm_core::ResizeEdge::BottomLeft),
- xdg_toplevel::ResizeEdge::BottomRight => Some(srdwm_core::ResizeEdge::BottomRight),
- // `None` is a valid protocol value (the client leaves the edge
- // unspecified) but `WindowManager::start_resize` needs one --
- // there's nothing sensible to default it to that wouldn't be a
- // guess, so this is a no-op rather than picking one.
- _ => None,
- }) else {
- return;
- };
- if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
- let pos = crate::input::last_pointer_pos(self);
- self.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32);
- }
- }
-
- /// `maximize_request`/`unmaximize_request`/`fullscreen_request`/
- /// `unfullscreen_request`/`minimize_request` were all still smithay's
- /// default no-op (or configure-only) implementations - found
- /// investigating the `toggle_fullscreen` decoration bug above, by
- /// checking what else routes through the same `WindowManager` calls
- /// the titlebar-button click handlers in `input.rs` already use.
- /// These five are the *client-initiated* equivalent of those clicks: a
- /// client's own window-menu "Maximize", pressing F11, an HTML5 video
- /// going fullscreen, or (for a client that negotiated client-side
- /// decoration and draws its own titlebar, like Firefox) that titlebar's
- /// own maximize button - all ask the compositor to actually perform
- /// the state change via these requests rather than the compositor
- /// noticing on its own. Left unimplemented, every one of them was a
- /// silent no-op: the client's button did nothing, with no error and
- /// nothing to suggest why, from any app that relies on this instead of
- /// (or in addition to) a compositor-side keybinding.
- fn maximize_request(&mut self, surface: ToplevelSurface) {
- if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
- if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
- self.wm.borrow_mut().toggle_maximize(id);
- self.sync_geometry(id);
- crate::foreign_toplevel::send_state(self, id);
- }
- }
- surface.send_configure();
- }
-
- fn unmaximize_request(&mut self, surface: ToplevelSurface) {
- if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
- if self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
- self.wm.borrow_mut().toggle_maximize(id);
- self.sync_geometry(id);
- crate::foreign_toplevel::send_state(self, id);
- }
- }
- surface.send_configure();
- }
-
- /// `_output` (the client's requested target output) is ignored --
- /// single-seat, and every other fullscreen entry point (the titlebar
- /// button, `srd.window.fullscreen()`) already fullscreens on whatever
- /// monitor the window is already on, so this matches that instead of
- /// introducing an output-aware fullscreen path only this one request
- /// would use.
- fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<WlOutput>) {
- if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
- if !self.wm.borrow().is_fullscreen(id) {
- // `redraw_decoration_buffer` first, same reason
- // `set_decorated_from_mode` calls it before `sync_geometry`:
- // fullscreen also flips `Window.decorated`, and dropping
- // the decoration needs the buffer actually removed, not
- // just left stale for `sync_geometry`'s own resize-only
- // redraw check to skip.
- self.wm.borrow_mut().toggle_fullscreen(id);
- self.redraw_decoration_buffer(id);
- self.sync_geometry(id);
- crate::foreign_toplevel::send_state(self, id);
- }
- }
- surface.send_configure();
- }
-
- fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
- if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
- if self.wm.borrow().is_fullscreen(id) {
- self.wm.borrow_mut().toggle_fullscreen(id);
- self.redraw_decoration_buffer(id);
- self.sync_geometry(id);
- crate::foreign_toplevel::send_state(self, id);
- }
- }
- surface.send_configure();
- }
-
- /// No `send_configure` here, matching the pointer-driven
- /// `TitlebarHit::Minimize` handler in `input.rs`: minimizing doesn't
- /// change the window's own size, only whether it's currently shown, so
- /// there's nothing new to tell the client about its own geometry.
- fn minimize_request(&mut self, surface: ToplevelSurface) {
- if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
- self.wm.borrow_mut().minimize_window(id);
- crate::foreign_toplevel::send_state(self, id);
- }
- }
-
- /// Was a bare no-op - no `send_configure` at all. Per xdg-shell,
- /// `xdg_surface.configure` is required before a popup's first commit;
- /// real toolkits (confirmed live: GTK4's Wayland backend) block that
- /// commit in a synchronous roundtrip waiting for it, so every popup
- /// hung its client forever. GTK4 implements tooltips *and*
- /// `Gtk.Popover` as `xdg_popup`, so this fired on hovering almost any
- /// widget with a tooltip - confirmed by a peer session's gdb backtrace
- /// (blocked in `wl_display_dispatch_queue` under `gtk_widget_show`)
- /// after AGS wedged.
- ///
- /// Geometry is `positioner.get_geometry()` un-constrained - no
- /// on-screen clamping yet (`PositionerState::get_unconstrained_geometry`
- /// needs a target rect in the parent's surface-local space, which is a
- /// real follow-up, not this fix); an occasional popup placed near a
- /// screen edge may render partly off it, which is cosmetic, not a hang.
- fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) {
- surface.with_pending_state(|state| {
- state.geometry = positioner.get_geometry();
- state.positioner = positioner;
- });
- if surface.send_configure().is_err() {
- return;
- }
- let _ = self.popups.track_popup(smithay::desktop::PopupKind::Xdg(surface));
- }
-
- /// Implicit grab + dismiss-on-outside-click. Previously believed
- /// blocked on `CompState`'s `SeatHandler` associated types not
- /// satisfying `PopupManager::grab_popup`'s `WaylandFocus +
- /// From<PopupKind>` bound - rechecked while implementing
- /// `move_request`/`resize_request` (same trait, adjacent methods) and
- /// it turns out they already do: `KeyboardFocus`/`PointerFocus` are
- /// both plain `WlSurface`, smithay provides `impl From<PopupKind> for
- /// WlSurface` itself, and `WlSurface: From<WlSurface>` trivially. No
- /// blocker ever existed by the time of this pass; the bound just
- /// hadn't been rechecked since being noted as unmet.
- ///
- /// `self.seat.clone()` rather than resolving `_seat` (the client's
- /// `wl_seat` resource) via `Seat::from_resource` - this compositor
- /// only ever has the one seat, matching how `move_request`/
- /// `resize_request` already ignore the same parameter.
- fn grab(&mut self, surface: PopupSurface, _seat: wl_seat::WlSeat, serial: Serial) {
- let popup = PopupKind::Xdg(surface);
- let Ok(root) = find_popup_root_surface(&popup) else {
- log::warn!("POPUP-GRAB-DIAG find_popup_root_surface failed");
- return;
- };
- let seat = self.seat.clone();
- let grab = match self.popups.grab_popup(root, popup, &seat, serial) {
- Ok(g) => g,
- Err(e) => {
- log::warn!("POPUP-GRAB-DIAG grab_popup failed: {e:?}");
- return;
- }
- };
- log::warn!("POPUP-GRAB-DIAG grab established, has_pointer={} has_keyboard={}", seat.get_pointer().is_some(), seat.get_keyboard().is_some());
- if let Some(keyboard) = seat.get_keyboard() {
- keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial);
- }
- if let Some(pointer) = seat.get_pointer() {
- pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep);
- }
- }
-
- fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) {
- surface.with_pending_state(|state| {
- state.geometry = positioner.get_geometry();
- state.positioner = positioner;
- });
- surface.send_repositioned(token);
- }
-
- fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
- self.remove_window(surface.wl_surface());
- }
-}
-
-impl XdgDecorationHandler for CompState {
- /// Offers whichever mode `theme.decorations.default_mode`/`srd set
- /// decoration_mode` currently prefers - a client with a real opinion
- /// of its own still overrides this via `request_mode` below regardless
- /// of what's offered here; this only decides what a client with *no*
- /// preference ends up with. See `srdwm_core::ThemeConfig::
- /// default_decorated`'s own doc comment for why this is configurable
- /// rather than hardcoded to one mode.
- fn new_decoration(&mut self, toplevel: ToplevelSurface) {
- let offer = if self.wm.borrow().theme.default_decorated { DecorationMode::ServerSide } else { DecorationMode::ClientSide };
- toplevel.with_pending_state(|state| {
- state.decoration_mode = Some(offer);
- });
- }
-
- /// Honors whichever mode the client actually asked for, rather than
- /// always forcing server-side - and mirrors the result into our own
- /// `Window.decorated`, so a client drawing its own titlebar doesn't
- /// *also* get one drawn on top of it by us.
- ///
- /// Always forcing `ServerSide` (what this used to do) is why some
- /// clients ended up with two sets of window buttons: Firefox requests
- /// client-side decoration when its own "use system titlebar" setting
- /// is off, and draws its own close/minimize/maximize row regardless of
- /// what the compositor grants - so forcing server-side just added
- /// srdwm's row on top of the one Firefox was drawing anyway, instead
- /// of preventing it. Respecting the request means srdwm steps out of
- /// the way for exactly those clients, while everything that accepts
- /// (or has no preference and gets offered) server-side still gets our
- /// titlebar as before.
- fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) {
- toplevel.with_pending_state(|state| {
- state.decoration_mode = Some(mode);
- });
- toplevel.send_configure();
- self.set_decorated_from_mode(toplevel.wl_surface(), mode == DecorationMode::ServerSide);
- }
-
- /// The client dropped its decoration-mode preference. `new_decoration`
- /// already offers the configured default as the mode the next
- /// configure will carry, so mirror that same default here rather than
- /// leaving whatever mode was negotiated before this - otherwise a
- /// client that requests one mode, then later unsets it expecting the
- /// default back, would stay stuck in that mode forever.
- fn unset_mode(&mut self, toplevel: ToplevelSurface) {
- let default_decorated = self.wm.borrow().theme.default_decorated;
- let mode = if default_decorated { DecorationMode::ServerSide } else { DecorationMode::ClientSide };
- toplevel.with_pending_state(|state| {
- state.decoration_mode = Some(mode);
- });
- toplevel.send_configure();
- self.set_decorated_from_mode(toplevel.wl_surface(), default_decorated);
- }
-}
-
-impl ShmHandler for CompState {
- fn shm_state(&self) -> &ShmState {
- &self.shm_state
- }
-}
-
-impl BufferHandler for CompState {
- fn buffer_destroyed(&mut self, _buffer: &WlBuffer) {}
-}
-
-impl DmabufHandler for CompState {
- fn dmabuf_state(&mut self) -> &mut DmabufState {
- &mut self.dmabuf_state
- }
-
- /// Validates a client's dmabuf by actually importing it wherever a
- /// renderer is reachable from here, so a genuinely bad buffer (wrong
- /// modifier, format the renderer doesn't support) gets the protocol
- /// error instead of silently rendering garbage later.
- ///
- /// That's only the udev backend: its `PixmanRenderer` lives inside
- /// `self.udev` (`UdevState`), a field of this same struct.
- /// `PixmanRenderer` supports dmabuf import despite being a pure
- /// software renderer - `dmabuf_formats()` only advertises the Linear
- /// modifier, which it imports by mmap'ing the buffer and reading it
- /// directly as pixels, no GPU involved. This is what actually answers
- /// `docs/PANEL_SUPPORT_TODO.md`'s P0.3: GTK4 allocates via its own
- /// EGL/gbm path against the real DRM render node (untouched by this
- /// compositor either way) and hands the result here as a Linear-
- /// modifier dmabuf, which pixman can read straight off.
- ///
- /// The winit (nested/dev) backend's `GlesRenderer` lives on
- /// `WaylandPlatform`, a sibling of `CompState`, not reachable from a
- /// method on `CompState` itself. Accepted there without eager
- /// validation - the buffer still gets imported the same way every
- /// other buffer type already is, lazily, the first time it is actually
- /// rendered via `render_elements_from_surface_tree`. Real hardware,
- /// where P0.3 actually bites, always goes through the udev path.
- fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
- match self.udev.as_mut() {
- Some(udev) => match udev.renderer.import_dmabuf(&dmabuf, None) {
- Ok(_) => {
- let _ = notifier.successful::<CompState>();
- }
- Err(e) => {
- log::warn!("udev: rejecting dmabuf import: {e}");
- notifier.failed();
- }
- },
- None => {
- let _ = notifier.successful::<CompState>();
- }
- }
- }
-}
-
-impl XdgActivationHandler for CompState {
- fn activation_state(&mut self) -> &mut XdgActivationState {
- &mut self.xdg_activation_state
- }
-
- /// A launcher spawns an app after first getting a token
- /// (`get_activation_token`) and handing it to the new process (usually
- /// via `XDG_ACTIVATION_TOKEN`); the app's own first window then
- /// presents that same token back here via `activate`, asking to be
- /// raised. Without this, that request was silently ignored - the new
- /// window opened and just sat there unfocused behind everything,
- /// exactly the gap `docs/PANEL_SUPPORT_TODO.md`'s P1 flagged.
- ///
- /// No token bookkeeping of our own: `token_created`'s default already
- /// accepts every token (fine for a single-user session with no
- /// cross-client trust boundary to enforce), so all that's left is
- /// mapping the activating `surface` to a `WindowId` and reusing the
- /// exact same `focus_window` path a dock's "activate" request already
- /// goes through (`foreign_toplevel.rs`). If the surface isn't tracked
- /// yet - the activation raced ahead of this window's own mapping --
- /// there is nothing to focus yet, so this is a no-op rather than an
- /// error; the protocol doesn't require honoring every activation.
- fn request_activation(&mut self, _token: XdgActivationToken, _token_data: XdgActivationTokenData, surface: WlSurface) {
- if let Some(&id) = self.surface_to_id.get(&surface) {
- crate::input::focus_window(self, id);
- }
- }
-}
-
-/// `zwp_text_input_manager_v3` + `zwp_input_method_manager_v2`: lets a real
-/// input method (fcitx5, ibus, any CJK/dead-key/emoji-picker IME) attach to
-/// whichever surface has keyboard focus and draw its own candidate/
-/// composition popup. Without these two globals a client that only speaks
-/// text-input (most modern toolkits do, GTK4/Qt6 included) has no way to
-/// tell the compositor "I have an editable text field, here is its cursor
-/// rectangle" - every desktop app's search box, address bar, and chat
-/// input silently loses IME support, not just an edge case.
-///
-/// Focus tracking needs *no* wiring here at all: `CompState::KeyboardFocus`
-/// is a plain `WlSurface`, and smithay's own blanket `impl KeyboardTarget
-/// for WlSurface` already calls `seat.text_input().set_focus/.enter()/
-/// .leave()` and `seat.input_method().activate_input_method()/
-/// deactivate_input_method()` from inside `enter`/`leave` - which
-/// `set_keyboard_focus`'s existing `keyboard.set_focus(...)` call already
-/// triggers on every real focus change. The only things actually missing
-/// were the two manager globals and this handler for the popup surface
-/// lifecycle.
-impl smithay::wayland::input_method::InputMethodHandler for CompState {
- /// A candidate/composition window (an emoji picker, a CJK candidate
- /// list) just opened. Tracked as a regular [`PopupKind::InputMethod`]
- /// in the same [`PopupManager`](smithay::desktop::PopupManager) that
- /// already owns every `xdg_popup` - `elements::popup_render_elements`
- /// renders both kinds identically, so no separate render path is
- /// needed for this to actually become visible.
- fn new_popup(&mut self, surface: ImePopupSurface) {
- if let Err(e) = self.popups.track_popup(PopupKind::from(surface)) {
- log::warn!("input-method: failed to track popup: {e}");
- }
- }
-
- fn dismiss_popup(&mut self, surface: ImePopupSurface) {
- if let Some(parent) = surface.get_parent().map(|p| p.surface.clone()) {
- let _ = smithay::desktop::PopupManager::dismiss_popup(&parent, &PopupKind::from(surface));
- }
- }
-
- /// The IME moved its own popup (e.g. following the text cursor as the
- /// user types) - `PopupSurface::location()` already reflects the new
- /// position; nothing else needs updating on this side, matching every
- /// other smithay-based compositor's own no-op here.
- fn popup_repositioned(&mut self, _surface: ImePopupSurface) {}
-
- /// Where the IME should anchor its popup, in the parent surface's own
- /// output-independent (logical, window-relative-origin) space - same
- /// geometry `elements::popup_targets` already computes for xdg popups,
- /// reused here rather than duplicated. A window not yet tracked (the
- /// activation raced ahead of its own mapping) gets a default/zero rect,
- /// same "no-op rather than an error" stance as `request_activation`
- /// above.
- fn parent_geometry(&self, parent: &WlSurface) -> smithay::utils::Rectangle<i32, smithay::utils::Logical> {
- let Some(&id) = self.surface_to_id.get(parent) else {
- return smithay::utils::Rectangle::default();
- };
- let wm = self.wm.borrow();
- let Some(w) = wm.window(id) else {
- return smithay::utils::Rectangle::default();
- };
- let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 };
- smithay::utils::Rectangle::new((w.geometry.x, w.geometry.y + band).into(), (w.geometry.width as i32, w.geometry.height as i32).into())
- }
-}
-
-impl smithay::wayland::output::OutputHandler for CompState {}
-
-/// `wp_cursor_shape_v1`: lets a client ask for a *named* cursor (text,
-/// grab, resize edges, ...) instead of rendering and attaching its own
-/// surface. Its requests route straight into `SeatHandler::cursor_image`
-/// below, same as a client-drawn cursor surface does - no extra state on
-/// our side. Without this global at all, a client that only speaks this
-/// (increasingly the norm - recent GTK4/Firefox use it for most cursor
-/// changes) has no way to tell us the pointer should look like anything
-/// but whatever it last was, which reads as the cursor going stale, wrong,
-/// or simply disappearing depending on what was showing when the client
-/// gave up trying.
-///
-/// `TabletSeatHandler` is a supertrait bound of this protocol's `Dispatch`
-/// impl (cursor-shape covers tablet tools too); srdwm has no tablet
-/// support to speak of, so every method is left at its no-op default.
-impl TabletSeatHandler for CompState {}
-
-/// Fractional scaling. srdwm runs every output at scale 1, so there is
-/// nothing to compute - but the global has to exist, because clients that
-/// use it (notably wallpaper daemons) treat it as mandatory.
-impl smithay::wayland::fractional_scale::FractionalScaleHandler for CompState {}
-
-impl SeatHandler for CompState {
- type KeyboardFocus = WlSurface;
- type PointerFocus = WlSurface;
- type TouchFocus = WlSurface;
-
- fn seat_state(&mut self) -> &mut SeatState<Self> {
- &mut self.seat_state
- }
-
- fn focus_changed(&mut self, _seat: &Seat<Self>, _focused: Option<&WlSurface>) {}
- /// Clients set their own cursor (an I-beam over text, a hand over a
- /// link). Recorded here and drawn by the render paths - on a bare TTY
- /// nothing else would draw it. See `cursor.rs`.
- fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
- self.cursor_status = image;
- }
-}
-
-impl WlrLayerShellHandler for CompState {
- fn shell_state(&mut self) -> &mut WlrLayerShellState {
- &mut self.layer_shell_state
- }
-
- fn new_layer_surface(&mut self, surface: WlrLayerSurface, wl_output: Option<WlOutput>, _layer: Layer, namespace: String) {
- // Logged before anything else can early-return or panic: the
- // question this answers (see docs/PANEL_SUPPORT_TODO.md) is
- // whether this handler is reached AT ALL for a later
- // `get_layer_surface` request in a create -> commit -> destroy ->
- // commit-again -> create sequence, or whether the client's
- // dispatch is already dead by then and this never runs.
- log::debug!("layer-shell: new_layer_surface entered, surface={:?} namespace={namespace:?} output_named={}", surface.wl_surface().id(), wl_output.is_some());
- // A client may name the output it wants (a bar on a specific
- // monitor); if it doesn't, or names one we don't drive, it lands on
- // the primary output.
- let output = wl_output
- .as_ref()
- .and_then(|wl| self.output_for_wl(wl))
- .map(|e| e.output.clone())
- .or_else(|| self.primary_output().cloned());
- let Some(output) = output else {
- log::warn!("wayland: layer surface requested but no output exists yet");
- return;
- };
- // Paired with the debug log in `ensure_layer_initial_configure`'s
- // early return - see docs/PANEL_SUPPORT_TODO.md's P0. This is the
- // other half of "did map_layer actually succeed, and on which
- // output": logged unconditionally (not just on the error paths
- // that already existed) so a real reproduction shows both sides of
- // the handoff instead of just the failure.
- let surface_id = surface.wl_surface().id();
- let layer_surface = DesktopLayerSurface::new(surface, namespace);
- let result = layer_map_for_output(&output).map_layer(&layer_surface);
- match &result {
- Ok(()) => log::debug!("layer-shell: mapped surface {surface_id:?} onto output {}", output.name()),
- Err(e) => log::warn!("wayland: failed to map layer surface {surface_id:?}: {e}"),
- }
- }
-
- fn layer_destroyed(&mut self, surface: WlrLayerSurface) {
- // See the matching top-of-function log in `new_layer_surface`.
- log::debug!("layer-shell: layer_destroyed entered, surface={:?}", surface.wl_surface().id());
- // Marks this surface for the pre-commit-hook workaround in
- // `new_surface` - see that function's doc comment for the bug
- // this exists to route around.
- self.dead_layer_surfaces.insert(surface.wl_surface().clone());
- // GTK (confirmed live via an AGS peer session's WAYLAND_DEBUG trace)
- // reuses the same `wl_surface` for the next `get_layer_surface` role
- // rather than creating a fresh one - so without this, a "shown at
- // least once" flag from *this* role would leak onto the next one
- // and make `sync_layer_visibility` treat that new role's own
- // ack-configure commit as eligible to hide again, the same bug
- // `layer_surfaces_shown_once` exists to prevent, just reintroduced
- // for exactly the reused-surface case that matters here.
- self.layer_surfaces_shown_once.remove(surface.wl_surface());
- // The surface belongs to exactly one output's map, but which one is
- // the client's choice, so unmap from whichever holds it.
- for output in self.outputs().cloned().collect::<Vec<_>>() {
- let mut map = layer_map_for_output(&output);
- let found = map.layers().find(|l| l.layer_surface() == &surface).cloned();
- if let Some(layer) = found {
- // Same zone-change recompute `ensure_layer_initial_configure`
- // already does on every commit that changes a layer's
- // exclusive zone (state/layers.rs) - but this is the *only* place
- // that ever runs for a surface that goes away without one
- // last commit. `unmap_layer` alone doesn't trigger it:
- // reported live (by the AGS peer session) as a bar unmapping
- // for fullscreen yet `srd monitors` still reporting the
- // bar's old reserved_top for as long as fullscreen lasted --
- // harmless there only because fullscreen targets
- // `full_geometry`, which ignores the reservation anyway, but
- // wrong for anything that reads `usable`/`geometry` while a
- // bar is unmapped without exiting cleanly (a crash, not just
- // AGS's cooperative fullscreen hide).
- let zone_before = map.non_exclusive_zone();
- map.unmap_layer(&layer);
- let zone_after = map.non_exclusive_zone();
- if zone_after != zone_before {
- self.pending.borrow_mut().push(srdwm_core::Event::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0))));
- }
- break;
- }
- }
- // A lock/launcher surface holding exclusive keyboard focus just
- // vanished (crash, or a normal close) - don't leave focus dangling
- // on a dead surface.
- //
- // `sync_keyboard_focus`, not a bare `set_keyboard_focus(None)`: an
- // `OnDemand` layer surface (a launcher/quicksettings/datemenu
- // popup, per `wlr-layer-shell`) claiming focus on click
- // (`input.rs`'s `on_demand` branch) goes straight through
- // `set_keyboard_focus` without ever touching `WindowManager::
- // focused` - core has no concept of a layer surface to focus, so
- // it still correctly points at whatever real toplevel was focused
- // before the popup opened. Hardcoding `None` here threw that away
- // regardless, leaving nothing focused until the user happened to
- // click a window again - reported live (an AGS peer session's
- // user) as "focus never returns after using the bar". `sync_
- // keyboard_focus` reads that still-correct core state and restores
- // real Wayland focus to it, falling through to `None` only if core
- // genuinely has nothing focused either.
- if self.seat.get_keyboard().and_then(|k| k.current_focus()).as_ref() == Some(surface.wl_surface()) {
- crate::input::sync_keyboard_focus(self);
- }
- }
-}
-
-/// Clipboard/primary-selection/drag-and-drop.
-///
-/// All three selection protocols below (`wl_data_device_manager`,
-/// `zwp_primary_selection_v1`, `zwlr_data_control_manager_v1`) share
-/// smithay's single `SelectionHandler`. Every transfer here is
-/// *client-to-client*: one client owns the selection and writes the bytes
-/// itself, and smithay wires the two ends together without the data passing
-/// through us. `send_selection` is only ever called for a
-/// **compositor-provided** selection (one this WM set itself via
-/// `set_data_device_selection`), which srdwm never does - so it is
-/// deliberately left unimplemented rather than faked.
-impl SelectionHandler for CompState {
- type SelectionUserData = ();
-}
-
-impl DataDeviceHandler for CompState {
- fn data_device_state(&self) -> &DataDeviceState {
- &self.data_device_state
- }
-}
-
-// Drag-and-drop: the default trait methods already do the right thing for a
-// compositor that doesn't draw its own drag icon or offer server-side drag
-// sources - smithay runs the pointer grab and the offer/accept negotiation
-// internally. Both are implemented empty (rather than skipped) because
-// `DataDeviceHandler` requires them as supertraits.
-impl ClientDndGrabHandler for CompState {}
-impl ServerDndGrabHandler for CompState {}
-
-impl PrimarySelectionHandler for CompState {
- fn primary_selection_state(&self) -> &PrimarySelectionState {
- &self.primary_selection_state
- }
-}
-
-/// `zwlr_data_control_manager_v1`: lets a client read/watch the selection
-/// without ever holding keyboard focus. This is what `wl-paste --watch`
-/// (and thus `cliphist store`, which the user's session autostarts) needs
-/// - a focus-following clipboard manager is impossible without it.
-impl DataControlHandler for CompState {
- fn data_control_state(&self) -> &DataControlState {
- &self.data_control_state
- }
-}
-
-/// `ext_idle_notify_v1`. All the real logic (per-notification timers,
-/// resetting them on activity, honouring inhibition) already lives in
-/// smithay's own `IdleNotifierState` - this is just the getter it needs.
-/// See `input.rs`'s `notify_idle_activity` for the other half: nothing
-/// calls `notify_activity` on its own, that has to happen from every real
-/// input path.
-impl smithay::wayland::idle_notify::IdleNotifierHandler for CompState {
- fn idle_notifier_state(&mut self) -> &mut smithay::wayland::idle_notify::IdleNotifierState<Self> {
- &mut self.idle_notifier_state
- }
-}
-
-/// `zwp_idle_inhibit_manager_v1`. A video player (or anything else that
-/// wants the screen to stay on/unlocked while it runs) creates one of
-/// these tied to its own surface; as long as at least one is alive,
-/// `IdleNotifierState::set_is_inhibited` stops idle timers from firing at
-/// all - see `idle_inhibiting_surfaces`'s doc comment on `CompState` for
-/// the one simplification (not workspace-visibility-aware) this takes.
-impl smithay::wayland::idle_inhibit::IdleInhibitHandler for CompState {
- fn inhibit(&mut self, surface: WlSurface) {
- self.idle_inhibiting_surfaces.push(surface);
- self.idle_notifier_state.set_is_inhibited(true);
- }
-
- fn uninhibit(&mut self, surface: WlSurface) {
- self.idle_inhibiting_surfaces.retain(|s| s != &surface);
- self.idle_notifier_state.set_is_inhibited(!self.idle_inhibiting_surfaces.is_empty());
- }
-}
+use crate::state::CompState;
delegate_compositor!(CompState);
delegate_xdg_shell!(CompState);
@@ -815,6 +43,7 @@ delegate_dmabuf!(CompState);
delegate_xdg_activation!(CompState);
delegate_text_input_manager!(CompState);
delegate_input_method_manager!(CompState);
+delegate_virtual_keyboard_manager!(CompState);
delegate_seat!(CompState);
delegate_output!(CompState);
delegate_layer_shell!(CompState);
diff --git a/crates/wayland/src/protocols/buffer.rs b/crates/wayland/src/protocols/buffer.rs
new file mode 100644
index 0000000..7730de6
--- /dev/null
+++ b/crates/wayland/src/protocols/buffer.rs
@@ -0,0 +1,68 @@
+//! `wl_shm`/`wl_buffer`/`zwp_linux_dmabuf_v1`: the three buffer-transport
+//! protocols, grouped together since none has more than a handful of lines
+//! on its own.
+
+use smithay::backend::allocator::dmabuf::Dmabuf;
+use smithay::backend::renderer::ImportDma;
+use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
+use smithay::wayland::buffer::BufferHandler;
+use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
+use smithay::wayland::shm::{ShmHandler, ShmState};
+
+use crate::state::CompState;
+
+impl ShmHandler for CompState {
+ fn shm_state(&self) -> &ShmState {
+ &self.shm_state
+ }
+}
+
+impl BufferHandler for CompState {
+ fn buffer_destroyed(&mut self, _buffer: &WlBuffer) {}
+}
+
+impl DmabufHandler for CompState {
+ fn dmabuf_state(&mut self) -> &mut DmabufState {
+ &mut self.dmabuf_state
+ }
+
+ /// Validates a client's dmabuf by actually importing it wherever a
+ /// renderer is reachable from here, so a genuinely bad buffer (wrong
+ /// modifier, format the renderer doesn't support) gets the protocol
+ /// error instead of silently rendering garbage later.
+ ///
+ /// That's only the udev backend: its `PixmanRenderer` lives inside
+ /// `self.udev` (`UdevState`), a field of this same struct.
+ /// `PixmanRenderer` supports dmabuf import despite being a pure
+ /// software renderer - `dmabuf_formats()` only advertises the Linear
+ /// modifier, which it imports by mmap'ing the buffer and reading it
+ /// directly as pixels, no GPU involved. This is what actually answers
+ /// `docs/PANEL_SUPPORT_TODO.md`'s P0.3: GTK4 allocates via its own
+ /// EGL/gbm path against the real DRM render node (untouched by this
+ /// compositor either way) and hands the result here as a Linear-
+ /// modifier dmabuf, which pixman can read straight off.
+ ///
+ /// The winit (nested/dev) backend's `GlesRenderer` lives on
+ /// `WaylandPlatform`, a sibling of `CompState`, not reachable from a
+ /// method on `CompState` itself. Accepted there without eager
+ /// validation - the buffer still gets imported the same way every
+ /// other buffer type already is, lazily, the first time it is actually
+ /// rendered via `render_elements_from_surface_tree`. Real hardware,
+ /// where P0.3 actually bites, always goes through the udev path.
+ fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
+ match self.udev.as_mut() {
+ Some(udev) => match udev.renderer.import_dmabuf(&dmabuf, None) {
+ Ok(_) => {
+ let _ = notifier.successful::<CompState>();
+ }
+ Err(e) => {
+ log::warn!("udev: rejecting dmabuf import: {e}");
+ notifier.failed();
+ }
+ },
+ None => {
+ let _ = notifier.successful::<CompState>();
+ }
+ }
+ }
+}
diff --git a/crates/wayland/src/protocols/compositor.rs b/crates/wayland/src/protocols/compositor.rs
new file mode 100644
index 0000000..f7e7b74
--- /dev/null
+++ b/crates/wayland/src/protocols/compositor.rs
@@ -0,0 +1,182 @@
+//! `wl_compositor`/`wl_surface`: surface creation and the per-commit
+//! bookkeeping every other protocol handler in this module tree depends on
+//! (window mapping, layer-surface visibility, popup lifecycle).
+
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::Client;
+use smithay::wayland::compositor::{add_pre_commit_hook, with_states, CompositorClientState, CompositorHandler, CompositorState};
+use smithay::wayland::shell::wlr_layer::LayerSurfaceCachedState;
+
+use crate::state::{ClientState, CompState};
+
+impl CompositorHandler for CompState {
+ fn compositor_state(&mut self) -> &mut CompositorState {
+ &mut self.compositor_state
+ }
+
+ fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState {
+ // Two possible client kinds now: our own `ClientState` for regular
+ // Wayland clients, or smithay's `XWaylandClientData` for the single
+ // XWayland client (see `xwayland.rs`) - both carry a
+ // `CompositorClientState`, just under different wrapper types.
+ if let Some(state) = client.get_data::<ClientState>() {
+ return &state.compositor_state;
+ }
+ &client.get_data::<smithay::xwayland::XWaylandClientData>().expect("client is neither ours nor XWayland's").compositor_state
+ }
+
+ /// Workaround for a real smithay bug (see docs/PANEL_SUPPORT_TODO.md and
+ /// `layer_destroyed` below): destroying a `zwlr_layer_surface_v1` role
+ /// resets the surface's `LayerSurfaceCachedState` to
+ /// `Default::default()` (size 0x0, no anchor) rather than removing it,
+ /// but the pre-commit hook smithay itself registers at
+ /// `get_layer_surface` time keeps validating that state against every
+ /// future commit regardless of whether the role still exists --
+ /// tripping its own `width/height 0 requested without ... anchors`
+ /// check and posting `invalid_size`, which kills the client's whole
+ /// connection over what is protocol-legal (committing a now-roleless
+ /// surface).
+ ///
+ /// Fixed by registering our own pre-commit hook here, in `new_surface`
+ /// - called at `wl_compositor.create_surface`, strictly before any
+ /// later `get_layer_surface` on the same surface could register
+ /// smithay's own hook. Hooks run in registration order (`tree.rs`:
+ /// `pre_commit_hooks` is a plain `Vec`, pushed and iterated in order),
+ /// so ours always runs first and can neutralize the stale reset state
+ /// before smithay's hook ever inspects it. This depends on that
+ /// ordering guarantee holding in future smithay versions - it isn't
+ /// documented as an API contract, just an implementation detail
+ /// confirmed against 0.7.0's source - so re-check this file against
+ /// whatever smithay version replaces it.
+ ///
+ /// Cost: one closure registered per `wl_surface` (not just layer
+ /// surfaces, since we don't know in advance which ones will become
+ /// one), each a no-op unless that exact surface is in
+ /// `dead_layer_surfaces`.
+ fn new_surface(&mut self, surface: &WlSurface) {
+ add_pre_commit_hook::<CompState, _>(surface, |state, _dh, surface| {
+ if !state.dead_layer_surfaces.contains(surface) {
+ return;
+ }
+ with_states(surface, |states| {
+ let mut cached = states.cached_state.get::<LayerSurfaceCachedState>();
+ let pending = cached.pending();
+ if pending.size.w == 0 && !pending.anchor.anchored_horizontally() {
+ pending.size.w = 1;
+ }
+ if pending.size.h == 0 && !pending.anchor.anchored_vertically() {
+ pending.size.h = 1;
+ }
+ });
+ });
+ }
+
+ fn commit(&mut self, surface: &WlSurface) {
+ smithay::backend::renderer::utils::on_commit_buffer_handler::<CompState>(surface);
+ // XWayland's association of an X11 window with this wl_surface can
+ // arrive at any point relative to the map request (see
+ // `xwayland.rs`'s module docs); `surface_associated` handles the
+ // common ordering, this retries the surfaces still waiting on a
+ // commit to actually make that association queryable.
+ self.retry_pending_x11_windows();
+ if let Some(&id) = self.surface_to_id.get(surface) {
+ if let Some(w) = self.id_to_window.get(&id) {
+ w.on_commit();
+ }
+ // See `content_epoch`'s doc comment: this is the only per-commit
+ // signal the udev backend's rounded-corner mask cache has to
+ // invalidate itself, since content can change every frame,
+ // independent of the geometry-driven points `redraw_decoration_
+ // buffer` already runs at.
+ *self.content_epoch.entry(id).or_insert(0) += 1;
+ crate::state::sync_toplevel_metadata(self, id, surface);
+ // `redraw_decoration_buffer` reads `dwindow.geometry()` (via
+ // `effective_frame`) to size the border/titlebar/shadow against
+ // what the client's surface *really* committed - but nothing
+ // updates that value except this very commit
+ // (`on_commit()` above). Without a call here, a client whose
+ // first real commit settles at a different size than what was
+ // requested (a terminal snapping to a whole character-cell
+ // grid) wouldn't get corrected decoration until some unrelated
+ // trigger (a resize, a focus change) happened to call this
+ // again - cheap regardless, since the signature check inside
+ // makes every commit that didn't actually change the *visible*
+ // size an early return, not a real rebuild.
+ self.redraw_decoration_buffer(id);
+ // `sync_geometry` is what actually maps this window into
+ // `self.space` at `geom.x - content_offset.x, ...` - the same
+ // `content_offset` (`dwindow.geometry().loc`) the render loop
+ // (`udev/render.rs`'s per-frame `pos` computation) reads fresh
+ // on every single frame, straight off the live surface, not
+ // from any cache. Before this call existed here, `self.space`
+ // only got a fresh position from whichever *other* trigger last
+ // called `sync_geometry` (a resize, `maximize_request`, a
+ // decoration-mode change) - so a client that recommits a
+ // *different* `xdg_surface::set_window_geometry` on its own,
+ // with no accompanying resize (a GTK4/Firefox CSD window
+ // shrinking its declared shadow margin once real content
+ // replaces its first, provisional paint, concretely), left
+ // `self.space`'s cached position silently stale while the
+ // render loop kept self-correcting every frame - confirmed
+ // live via temporary diagnostic logging: a window's real render
+ // position and `self.space`'s own `element_under`-reported
+ // position for it disagreed by exactly one `content_offset`,
+ // 10 physical pixels on both axes for the Firefox window that
+ // exposed it. `refresh_pointer_focus`'s content-click path
+ // (`input.rs`) computes `win_relative` from *that* stale
+ // position, not the render loop's fresh one - every click on
+ // such a window was silently off by the same 10px the whole
+ // time it stayed unmapped-and-remapped-by-nothing-else, which
+ // reads as "clicks land near, but not on, whatever's visibly
+ // there" - worst for a window's own small CSD buttons,
+ // exactly what was reported live. Same idempotent-when-nothing-
+ // moved shape as `redraw_decoration_buffer` above: `map_element`
+ // itself is unconditional and cheap (a hashmap insert), and the
+ // one potentially-expensive part - sending a fresh
+ // `xdg_toplevel::configure` - stays gated on `size_changed`
+ // and the existing throttle, both untouched, so a commit that
+ // didn't change size never sends one just because this call is
+ // now here too.
+ self.sync_geometry(id);
+ } else {
+ // `surface` itself isn't a tracked window's root, but may be a
+ // descendant (subsurface) of one - a real commit still
+ // happened, just not on the surface `surface_to_id` keys off.
+ // `masked_content_buffer`'s own resolver
+ // (`rounded_corners_pixman::resolve_content_surface`) reads a
+ // *child* subsurface's buffer directly for the common GTK4/
+ // WebRender pattern (confirmed live: Firefox), so a repaint
+ // that only ever commits that child - which is the normal
+ // case, that's where the real content lives - must still
+ // bump this window's own `content_epoch`, or the masked-
+ // corner cache never sees a reason to invalidate and freezes
+ // on whatever the first frame happened to show. Bounded to a
+ // handful of hops purely as a safety net against a malformed
+ // subsurface tree looping back on itself - a real one is
+ // never more than one or two levels deep.
+ let mut ancestor = smithay::wayland::compositor::get_parent(surface);
+ for _ in 0..8 {
+ let Some(parent) = ancestor else { break };
+ if let Some(&id) = self.surface_to_id.get(&parent) {
+ *self.content_epoch.entry(id).or_insert(0) += 1;
+ break;
+ }
+ ancestor = smithay::wayland::compositor::get_parent(&parent);
+ }
+ }
+ // Before `ensure_layer_initial_configure`: if this commit just
+ // hid or re-showed a layer surface, `sync_layer_visibility` needs
+ // to unmap/re-map it first, so the lookup that function does via
+ // `layer_for_surface` sees the corrected state rather than acting
+ // on stale membership in `LayerMap`'s own list.
+ self.sync_layer_visibility(surface);
+ self.ensure_layer_initial_configure(surface);
+ // Advances a just-created popup from unmapped to mapped (needed for
+ // `PopupManager::popups_for_surface`, which `popup_render_elements`
+ // reads at render time) and prunes dead ones. Cheap and only does
+ // real work on a popup-role surface, so doing it on every commit
+ // rather than throttling is not worth the extra bookkeeping.
+ self.popups.commit(surface);
+ self.popups.cleanup();
+ }
+}
diff --git a/crates/wayland/src/protocols/idle.rs b/crates/wayland/src/protocols/idle.rs
new file mode 100644
index 0000000..a554453
--- /dev/null
+++ b/crates/wayland/src/protocols/idle.rs
@@ -0,0 +1,35 @@
+//! `ext_idle_notify_v1` + `zwp_idle_inhibit_manager_v1`.
+
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+
+use crate::state::CompState;
+
+/// `ext_idle_notify_v1`. All the real logic (per-notification timers,
+/// resetting them on activity, honouring inhibition) already lives in
+/// smithay's own `IdleNotifierState` - this is just the getter it needs.
+/// See `input.rs`'s `notify_idle_activity` for the other half: nothing
+/// calls `notify_activity` on its own, that has to happen from every real
+/// input path.
+impl smithay::wayland::idle_notify::IdleNotifierHandler for CompState {
+ fn idle_notifier_state(&mut self) -> &mut smithay::wayland::idle_notify::IdleNotifierState<Self> {
+ &mut self.idle_notifier_state
+ }
+}
+
+/// `zwp_idle_inhibit_manager_v1`. A video player (or anything else that
+/// wants the screen to stay on/unlocked while it runs) creates one of
+/// these tied to its own surface; as long as at least one is alive,
+/// `IdleNotifierState::set_is_inhibited` stops idle timers from firing at
+/// all - see `idle_inhibiting_surfaces`'s doc comment on `CompState` for
+/// the one simplification (not workspace-visibility-aware) this takes.
+impl smithay::wayland::idle_inhibit::IdleInhibitHandler for CompState {
+ fn inhibit(&mut self, surface: WlSurface) {
+ self.idle_inhibiting_surfaces.push(surface);
+ self.idle_notifier_state.set_is_inhibited(true);
+ }
+
+ fn uninhibit(&mut self, surface: WlSurface) {
+ self.idle_inhibiting_surfaces.retain(|s| s != &surface);
+ self.idle_notifier_state.set_is_inhibited(!self.idle_inhibiting_surfaces.is_empty());
+ }
+}
diff --git a/crates/wayland/src/protocols/input_method.rs b/crates/wayland/src/protocols/input_method.rs
new file mode 100644
index 0000000..525383f
--- /dev/null
+++ b/crates/wayland/src/protocols/input_method.rs
@@ -0,0 +1,86 @@
+//! `zwp_text_input_manager_v3` + `zwp_input_method_manager_v2`: lets a real
+//! input method (fcitx5, ibus, any CJK/dead-key/emoji-picker IME) attach to
+//! whichever surface has keyboard focus and draw its own candidate/
+//! composition popup. Without these two globals a client that only speaks
+//! text-input (most modern toolkits do, GTK4/Qt6 included) has no way to
+//! tell the compositor "I have an editable text field, here is its cursor
+//! rectangle" - every desktop app's search box, address bar, and chat
+//! input silently loses IME support, not just an edge case.
+//!
+//! Focus tracking needs *no* wiring here at all: `CompState::KeyboardFocus`
+//! is a plain `WlSurface`, and smithay's own blanket `impl KeyboardTarget
+//! for WlSurface` already calls `seat.text_input().set_focus/.enter()/
+//! .leave()` and `seat.input_method().activate_input_method()/
+//! deactivate_input_method()` from inside `enter`/`leave` - which
+//! `set_keyboard_focus`'s existing `keyboard.set_focus(...)` call already
+//! triggers on every real focus change. The only things actually missing
+//! were the two manager globals and this handler for the popup surface
+//! lifecycle.
+
+use smithay::desktop::PopupKind;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::wayland::input_method::PopupSurface as ImePopupSurface;
+
+use crate::state::CompState;
+
+impl smithay::wayland::input_method::InputMethodHandler for CompState {
+ /// A candidate/composition window (an emoji picker, a CJK candidate
+ /// list) just opened. Tracked as a regular [`PopupKind::InputMethod`]
+ /// in the same [`PopupManager`](smithay::desktop::PopupManager) that
+ /// already owns every `xdg_popup` - `elements::popup_render_elements`
+ /// renders both kinds identically, so no separate render path is
+ /// needed for this to actually become visible.
+ fn new_popup(&mut self, surface: ImePopupSurface) {
+ if let Err(e) = self.popups.track_popup(PopupKind::from(surface)) {
+ log::warn!("input-method: failed to track popup: {e}");
+ }
+ }
+
+ fn dismiss_popup(&mut self, surface: ImePopupSurface) {
+ if let Some(parent) = surface.get_parent().map(|p| p.surface.clone()) {
+ let _ = smithay::desktop::PopupManager::dismiss_popup(&parent, &PopupKind::from(surface));
+ }
+ }
+
+ /// The IME moved its own popup (e.g. following the text cursor as the
+ /// user types) - `PopupSurface::location()` already reflects the new
+ /// position; nothing else needs updating on this side, matching every
+ /// other smithay-based compositor's own no-op here.
+ fn popup_repositioned(&mut self, _surface: ImePopupSurface) {}
+
+ /// Where the IME should anchor its popup, in the parent surface's own
+ /// output-independent (logical, window-relative-origin) space - same
+ /// geometry `elements::popup_targets` already computes for xdg popups,
+ /// reused here rather than duplicated. A window not yet tracked (the
+ /// activation raced ahead of its own mapping) gets a default/zero rect,
+ /// same "no-op rather than an error" stance as `request_activation`
+ /// above.
+ fn parent_geometry(&self, parent: &WlSurface) -> smithay::utils::Rectangle<i32, smithay::utils::Logical> {
+ let Some(&id) = self.surface_to_id.get(parent) else {
+ return smithay::utils::Rectangle::default();
+ };
+ let (geometry, decorated) = {
+ let wm = self.wm.borrow();
+ let Some(w) = wm.window(id) else {
+ return smithay::utils::Rectangle::default();
+ };
+ (w.geometry, w.decorated)
+ };
+ let band = if decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 };
+ // `content_offset`/`effective_frame`: same corrections every other
+ // real position/size computation in this codebase applies (see
+ // `state/geometry.rs::effective_frame`'s doc comment) - missed
+ // here originally, so an IME popup anchored against a CSD window's
+ // raw, unshifted geometry instead of its real visible content,
+ // same class of drift as the border/screenshot gaps fixed
+ // elsewhere.
+ let content_offset = self.id_to_window.get(&id).map(|w| w.geometry().loc).unwrap_or_default();
+ // `frame.height` includes the titlebar band (see `effective_frame`'s
+ // own doc comment) - subtracted back out here since this rect is
+ // meant to cover the content area only, matching the original
+ // (pre-fix) code's own intent for `w.geometry.height`.
+ let frame = self.effective_frame(id, geometry);
+ let content_height = (frame.height as i32 - band).max(0);
+ smithay::utils::Rectangle::new((frame.x - content_offset.x, frame.y + band - content_offset.y).into(), (frame.width as i32, content_height).into())
+ }
+}
diff --git a/crates/wayland/src/protocols/layer_shell.rs b/crates/wayland/src/protocols/layer_shell.rs
new file mode 100644
index 0000000..931ddb4
--- /dev/null
+++ b/crates/wayland/src/protocols/layer_shell.rs
@@ -0,0 +1,117 @@
+//! `zwlr_layer_shell_v1`: panels, bars, launchers, and other output-anchored
+//! shell surfaces (AGS's own bar and popups, notably).
+
+use smithay::desktop::{layer_map_for_output, LayerSurface as DesktopLayerSurface};
+use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
+use smithay::reexports::wayland_server::Resource;
+use smithay::wayland::shell::wlr_layer::{Layer, LayerSurface as WlrLayerSurface, WlrLayerShellHandler, WlrLayerShellState};
+
+use crate::state::CompState;
+
+impl WlrLayerShellHandler for CompState {
+ fn shell_state(&mut self) -> &mut WlrLayerShellState {
+ &mut self.layer_shell_state
+ }
+
+ fn new_layer_surface(&mut self, surface: WlrLayerSurface, wl_output: Option<WlOutput>, _layer: Layer, namespace: String) {
+ // Logged before anything else can early-return or panic: the
+ // question this answers (see docs/PANEL_SUPPORT_TODO.md) is
+ // whether this handler is reached AT ALL for a later
+ // `get_layer_surface` request in a create -> commit -> destroy ->
+ // commit-again -> create sequence, or whether the client's
+ // dispatch is already dead by then and this never runs.
+ log::debug!("layer-shell: new_layer_surface entered, surface={:?} namespace={namespace:?} output_named={}", surface.wl_surface().id(), wl_output.is_some());
+ // A client may name the output it wants (a bar on a specific
+ // monitor); if it doesn't, or names one we don't drive, it lands on
+ // the primary output.
+ let output = wl_output
+ .as_ref()
+ .and_then(|wl| self.output_for_wl(wl))
+ .map(|e| e.output.clone())
+ .or_else(|| self.primary_output().cloned());
+ let Some(output) = output else {
+ log::warn!("wayland: layer surface requested but no output exists yet");
+ return;
+ };
+ // Paired with the debug log in `ensure_layer_initial_configure`'s
+ // early return - see docs/PANEL_SUPPORT_TODO.md's P0. This is the
+ // other half of "did map_layer actually succeed, and on which
+ // output": logged unconditionally (not just on the error paths
+ // that already existed) so a real reproduction shows both sides of
+ // the handoff instead of just the failure.
+ let surface_id = surface.wl_surface().id();
+ let layer_surface = DesktopLayerSurface::new(surface, namespace);
+ let result = layer_map_for_output(&output).map_layer(&layer_surface);
+ match &result {
+ Ok(()) => log::debug!("layer-shell: mapped surface {surface_id:?} onto output {}", output.name()),
+ Err(e) => log::warn!("wayland: failed to map layer surface {surface_id:?}: {e}"),
+ }
+ }
+
+ fn layer_destroyed(&mut self, surface: WlrLayerSurface) {
+ // See the matching top-of-function log in `new_layer_surface`.
+ log::debug!("layer-shell: layer_destroyed entered, surface={:?}", surface.wl_surface().id());
+ // Marks this surface for the pre-commit-hook workaround in
+ // `new_surface` - see that function's doc comment for the bug
+ // this exists to route around.
+ self.dead_layer_surfaces.insert(surface.wl_surface().clone());
+ // GTK (confirmed live via an AGS peer session's WAYLAND_DEBUG trace)
+ // reuses the same `wl_surface` for the next `get_layer_surface` role
+ // rather than creating a fresh one - so without this, a "shown at
+ // least once" flag from *this* role would leak onto the next one
+ // and make `sync_layer_visibility` treat that new role's own
+ // ack-configure commit as eligible to hide again, the same bug
+ // `layer_surfaces_shown_once` exists to prevent, just reintroduced
+ // for exactly the reused-surface case that matters here.
+ self.layer_surfaces_shown_once.remove(surface.wl_surface());
+ // The surface belongs to exactly one output's map, but which one is
+ // the client's choice, so unmap from whichever holds it.
+ for output in self.outputs().cloned().collect::<Vec<_>>() {
+ let mut map = layer_map_for_output(&output);
+ let found = map.layers().find(|l| l.layer_surface() == &surface).cloned();
+ if let Some(layer) = found {
+ // Same zone-change recompute `ensure_layer_initial_configure`
+ // already does on every commit that changes a layer's
+ // exclusive zone (state/layers.rs) - but this is the *only* place
+ // that ever runs for a surface that goes away without one
+ // last commit. `unmap_layer` alone doesn't trigger it:
+ // reported live (by the AGS peer session) as a bar unmapping
+ // for fullscreen yet `srd monitors` still reporting the
+ // bar's old reserved_top for as long as fullscreen lasted --
+ // harmless there only because fullscreen targets
+ // `full_geometry`, which ignores the reservation anyway, but
+ // wrong for anything that reads `usable`/`geometry` while a
+ // bar is unmapped without exiting cleanly (a crash, not just
+ // AGS's cooperative fullscreen hide).
+ let zone_before = map.non_exclusive_zone();
+ map.unmap_layer(&layer);
+ let zone_after = map.non_exclusive_zone();
+ if zone_after != zone_before {
+ self.pending.borrow_mut().push(srdwm_core::Event::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0))));
+ }
+ break;
+ }
+ }
+ // A lock/launcher surface holding exclusive keyboard focus just
+ // vanished (crash, or a normal close) - don't leave focus dangling
+ // on a dead surface.
+ //
+ // `sync_keyboard_focus`, not a bare `set_keyboard_focus(None)`: an
+ // `OnDemand` layer surface (a launcher/quicksettings/datemenu
+ // popup, per `wlr-layer-shell`) claiming focus on click
+ // (`input.rs`'s `on_demand` branch) goes straight through
+ // `set_keyboard_focus` without ever touching `WindowManager::
+ // focused` - core has no concept of a layer surface to focus, so
+ // it still correctly points at whatever real toplevel was focused
+ // before the popup opened. Hardcoding `None` here threw that away
+ // regardless, leaving nothing focused until the user happened to
+ // click a window again - reported live (an AGS peer session's
+ // user) as "focus never returns after using the bar". `sync_
+ // keyboard_focus` reads that still-correct core state and restores
+ // real Wayland focus to it, falling through to `None` only if core
+ // genuinely has nothing focused either.
+ if self.seat.get_keyboard().and_then(|k| k.current_focus()).as_ref() == Some(surface.wl_surface()) {
+ crate::input::sync_keyboard_focus(self);
+ }
+ }
+}
diff --git a/crates/wayland/src/protocols/misc.rs b/crates/wayland/src/protocols/misc.rs
new file mode 100644
index 0000000..7999ec3
--- /dev/null
+++ b/crates/wayland/src/protocols/misc.rs
@@ -0,0 +1,30 @@
+//! Small protocol handlers whose entire implementation is smithay's own
+//! no-op default - the global still has to exist for clients that treat it
+//! as mandatory, but there's nothing for this compositor to do in response.
+
+use smithay::wayland::tablet_manager::TabletSeatHandler;
+
+use crate::state::CompState;
+
+impl smithay::wayland::output::OutputHandler for CompState {}
+
+/// `wp_cursor_shape_v1`: lets a client ask for a *named* cursor (text,
+/// grab, resize edges, ...) instead of rendering and attaching its own
+/// surface. Its requests route straight into `SeatHandler::cursor_image`
+/// (see `seat.rs`), same as a client-drawn cursor surface does - no extra
+/// state on our side. Without this global at all, a client that only speaks
+/// this (increasingly the norm - recent GTK4/Firefox use it for most
+/// cursor changes) has no way to tell us the pointer should look like
+/// anything but whatever it last was, which reads as the cursor going
+/// stale, wrong, or simply disappearing depending on what was showing when
+/// the client gave up trying.
+///
+/// `TabletSeatHandler` is a supertrait bound of this protocol's `Dispatch`
+/// impl (cursor-shape covers tablet tools too); srdwm has no tablet
+/// support to speak of, so every method is left at its no-op default.
+impl TabletSeatHandler for CompState {}
+
+/// Fractional scaling. srdwm runs every output at scale 1, so there is
+/// nothing to compute - but the global has to exist, because clients that
+/// use it (notably wallpaper daemons) treat it as mandatory.
+impl smithay::wayland::fractional_scale::FractionalScaleHandler for CompState {}
diff --git a/crates/wayland/src/protocols/seat.rs b/crates/wayland/src/protocols/seat.rs
new file mode 100644
index 0000000..bd7ae4c
--- /dev/null
+++ b/crates/wayland/src/protocols/seat.rs
@@ -0,0 +1,31 @@
+//! `wl_seat`: keyboard/pointer/touch focus types and the client-set cursor
+//! image.
+
+use smithay::input::pointer::CursorImageStatus;
+use smithay::input::{Seat, SeatHandler, SeatState};
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+
+use crate::state::CompState;
+
+impl SeatHandler for CompState {
+ type KeyboardFocus = WlSurface;
+ type PointerFocus = WlSurface;
+ type TouchFocus = WlSurface;
+
+ fn seat_state(&mut self) -> &mut SeatState<Self> {
+ &mut self.seat_state
+ }
+
+ fn focus_changed(&mut self, _seat: &Seat<Self>, _focused: Option<&WlSurface>) {}
+ /// Clients set their own cursor (an I-beam over text, a hand over a
+ /// link). Recorded here and drawn by the render paths - on a bare TTY
+ /// nothing else would draw it. See `cursor.rs`.
+ fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
+ self.cursor_status = image;
+ // The client has now explicitly claimed the cursor - see
+ // `decoration_cursor_active`'s own doc comment and `input.rs::
+ // update_cursor_shape` for why this has to be tracked separately
+ // from just overwriting `cursor_status`.
+ self.decoration_cursor_active = false;
+ }
+}
diff --git a/crates/wayland/src/protocols/selection.rs b/crates/wayland/src/protocols/selection.rs
new file mode 100644
index 0000000..fadb0ca
--- /dev/null
+++ b/crates/wayland/src/protocols/selection.rs
@@ -0,0 +1,52 @@
+//! Clipboard/primary-selection/drag-and-drop.
+//!
+//! All three selection protocols below (`wl_data_device_manager`,
+//! `zwp_primary_selection_v1`, `zwlr_data_control_manager_v1`) share
+//! smithay's single `SelectionHandler`. Every transfer here is
+//! *client-to-client*: one client owns the selection and writes the bytes
+//! itself, and smithay wires the two ends together without the data passing
+//! through us. `send_selection` is only ever called for a
+//! **compositor-provided** selection (one this WM set itself via
+//! `set_data_device_selection`), which srdwm never does - so it is
+//! deliberately left unimplemented rather than faked.
+
+use smithay::wayland::selection::data_device::{ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler};
+use smithay::wayland::selection::primary_selection::{PrimarySelectionHandler, PrimarySelectionState};
+use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState};
+use smithay::wayland::selection::SelectionHandler;
+
+use crate::state::CompState;
+
+impl SelectionHandler for CompState {
+ type SelectionUserData = ();
+}
+
+impl DataDeviceHandler for CompState {
+ fn data_device_state(&self) -> &DataDeviceState {
+ &self.data_device_state
+ }
+}
+
+// Drag-and-drop: the default trait methods already do the right thing for a
+// compositor that doesn't draw its own drag icon or offer server-side drag
+// sources - smithay runs the pointer grab and the offer/accept negotiation
+// internally. Both are implemented empty (rather than skipped) because
+// `DataDeviceHandler` requires them as supertraits.
+impl ClientDndGrabHandler for CompState {}
+impl ServerDndGrabHandler for CompState {}
+
+impl PrimarySelectionHandler for CompState {
+ fn primary_selection_state(&self) -> &PrimarySelectionState {
+ &self.primary_selection_state
+ }
+}
+
+/// `zwlr_data_control_manager_v1`: lets a client read/watch the selection
+/// without ever holding keyboard focus. This is what `wl-paste --watch`
+/// (and thus `cliphist store`, which the user's session autostarts) needs
+/// - a focus-following clipboard manager is impossible without it.
+impl DataControlHandler for CompState {
+ fn data_control_state(&self) -> &DataControlState {
+ &self.data_control_state
+ }
+}
diff --git a/crates/wayland/src/protocols/xdg_activation.rs b/crates/wayland/src/protocols/xdg_activation.rs
new file mode 100644
index 0000000..846a213
--- /dev/null
+++ b/crates/wayland/src/protocols/xdg_activation.rs
@@ -0,0 +1,36 @@
+//! `xdg_activation_v1`: a launcher hands a spawned app a token, and the
+//! app's own first window presents it back to ask to be raised and focused.
+
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::wayland::xdg_activation::{XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData};
+
+use crate::state::CompState;
+
+impl XdgActivationHandler for CompState {
+ fn activation_state(&mut self) -> &mut XdgActivationState {
+ &mut self.xdg_activation_state
+ }
+
+ /// A launcher spawns an app after first getting a token
+ /// (`get_activation_token`) and handing it to the new process (usually
+ /// via `XDG_ACTIVATION_TOKEN`); the app's own first window then
+ /// presents that same token back here via `activate`, asking to be
+ /// raised. Without this, that request was silently ignored - the new
+ /// window opened and just sat there unfocused behind everything,
+ /// exactly the gap `docs/PANEL_SUPPORT_TODO.md`'s P1 flagged.
+ ///
+ /// No token bookkeeping of our own: `token_created`'s default already
+ /// accepts every token (fine for a single-user session with no
+ /// cross-client trust boundary to enforce), so all that's left is
+ /// mapping the activating `surface` to a `WindowId` and reusing the
+ /// exact same `focus_window` path a dock's "activate" request already
+ /// goes through (`foreign_toplevel.rs`). If the surface isn't tracked
+ /// yet - the activation raced ahead of this window's own mapping --
+ /// there is nothing to focus yet, so this is a no-op rather than an
+ /// error; the protocol doesn't require honoring every activation.
+ fn request_activation(&mut self, _token: XdgActivationToken, _token_data: XdgActivationTokenData, surface: WlSurface) {
+ if let Some(&id) = self.surface_to_id.get(&surface) {
+ crate::input::focus_window(self, id);
+ }
+ }
+}
diff --git a/crates/wayland/src/protocols/xdg_decoration.rs b/crates/wayland/src/protocols/xdg_decoration.rs
new file mode 100644
index 0000000..25973c0
--- /dev/null
+++ b/crates/wayland/src/protocols/xdg_decoration.rs
@@ -0,0 +1,63 @@
+//! `zxdg_decoration_manager_v1`: negotiates whether a toplevel draws its own
+//! (client-side) chrome or lets us draw it (server-side).
+
+use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode;
+use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
+use smithay::wayland::shell::xdg::ToplevelSurface;
+
+use crate::state::CompState;
+
+impl XdgDecorationHandler for CompState {
+ /// Offers whichever mode `theme.decorations.default_mode`/`srd set
+ /// decoration_mode` currently prefers - a client with a real opinion
+ /// of its own still overrides this via `request_mode` below regardless
+ /// of what's offered here; this only decides what a client with *no*
+ /// preference ends up with. See `srdwm_core::ThemeConfig::
+ /// default_decorated`'s own doc comment for why this is configurable
+ /// rather than hardcoded to one mode.
+ fn new_decoration(&mut self, toplevel: ToplevelSurface) {
+ let offer = if self.wm.borrow().theme.default_decorated { DecorationMode::ServerSide } else { DecorationMode::ClientSide };
+ toplevel.with_pending_state(|state| {
+ state.decoration_mode = Some(offer);
+ });
+ }
+
+ /// Honors whichever mode the client actually asked for, rather than
+ /// always forcing server-side - and mirrors the result into our own
+ /// `Window.decorated`, so a client drawing its own titlebar doesn't
+ /// *also* get one drawn on top of it by us.
+ ///
+ /// Always forcing `ServerSide` (what this used to do) is why some
+ /// clients ended up with two sets of window buttons: Firefox requests
+ /// client-side decoration when its own "use system titlebar" setting
+ /// is off, and draws its own close/minimize/maximize row regardless of
+ /// what the compositor grants - so forcing server-side just added
+ /// srdwm's row on top of the one Firefox was drawing anyway, instead
+ /// of preventing it. Respecting the request means srdwm steps out of
+ /// the way for exactly those clients, while everything that accepts
+ /// (or has no preference and gets offered) server-side still gets our
+ /// titlebar as before.
+ fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) {
+ toplevel.with_pending_state(|state| {
+ state.decoration_mode = Some(mode);
+ });
+ toplevel.send_configure();
+ self.set_decorated_from_mode(toplevel.wl_surface(), mode == DecorationMode::ServerSide);
+ }
+
+ /// The client dropped its decoration-mode preference. `new_decoration`
+ /// already offers the configured default as the mode the next
+ /// configure will carry, so mirror that same default here rather than
+ /// leaving whatever mode was negotiated before this - otherwise a
+ /// client that requests one mode, then later unsets it expecting the
+ /// default back, would stay stuck in that mode forever.
+ fn unset_mode(&mut self, toplevel: ToplevelSurface) {
+ let default_decorated = self.wm.borrow().theme.default_decorated;
+ let mode = if default_decorated { DecorationMode::ServerSide } else { DecorationMode::ClientSide };
+ toplevel.with_pending_state(|state| {
+ state.decoration_mode = Some(mode);
+ });
+ toplevel.send_configure();
+ self.set_decorated_from_mode(toplevel.wl_surface(), default_decorated);
+ }
+}
diff --git a/crates/wayland/src/protocols/xdg_shell.rs b/crates/wayland/src/protocols/xdg_shell.rs
new file mode 100644
index 0000000..4fe0f6f
--- /dev/null
+++ b/crates/wayland/src/protocols/xdg_shell.rs
@@ -0,0 +1,258 @@
+//! `xdg_shell`: toplevel and popup lifecycle, and the client-initiated
+//! move/resize/maximize/fullscreen/minimize requests a CSD client sends
+//! instead of (or alongside) the pointer-driven titlebar handlers in
+//! `input.rs`.
+
+use smithay::desktop::{find_popup_root_surface, PopupKeyboardGrab, PopupKind, PopupPointerGrab};
+use smithay::input::pointer::Focus;
+use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
+use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
+use smithay::reexports::wayland_server::protocol::wl_seat;
+use smithay::reexports::wayland_server::Resource;
+use smithay::utils::Serial;
+use smithay::wayland::shell::xdg::{PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState};
+
+use crate::state::CompState;
+
+impl XdgShellHandler for CompState {
+ fn xdg_shell_state(&mut self) -> &mut XdgShellState {
+ &mut self.xdg_shell_state
+ }
+
+ fn new_toplevel(&mut self, surface: ToplevelSurface) {
+ self.new_managed_window(surface);
+ }
+
+ /// `move_request`/`resize_request` were also still smithay's default
+ /// no-op implementations - a much larger gap than the five below:
+ /// this is *how a client-side-decorated window gets dragged or resized
+ /// by its own titlebar/edges at all*. A window we draw our own
+ /// decoration for never needed this (`TitlebarHit::Drag`/`Resize` in
+ /// `input.rs` detect the click directly, since we own those pixels),
+ /// but a window that negotiated client-side decoration and draws its
+ /// own titlebar - Firefox, and most GTK4 apps by default - handles
+ /// the click itself and then asks the compositor to actually perform
+ /// the move/resize via exactly these two requests. Left unimplemented,
+ /// dragging or resizing any such window by its own chrome did
+ /// nothing at all - the only way to reposition it was the
+ /// modifier+drag-anywhere gesture (`bindm`), which most users have no
+ /// reason to know exists and doesn't work for resize-from-a-specific-
+ /// edge at all. Reuses the exact same `WindowManager::start_drag`/
+ /// `start_resize` the pointer-driven titlebar handlers call --
+ /// `handle_pointer_position`/`handle_pointer_button` already drive any
+ /// in-progress drag/resize to completion on subsequent motion/release
+ /// regardless of what started it, so no smithay pointer grab is
+ /// needed here at all, just the same start call from a different
+ /// trigger.
+ fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) {
+ // Temporary: added to trace a live report that dragging a CSD
+ // window (Firefox) by its own tab strip/header bar does nothing --
+ // this is the only way to tell "the client never sent xdg_toplevel
+ // ::move at all" apart from "it sent it and something downstream
+ // of here didn't follow through." Remove once that's settled.
+ match self.surface_to_id.get(surface.wl_surface()) {
+ Some(&id) => {
+ let pos = crate::input::last_pointer_pos(self);
+ log::info!("move_request: window {id:?} at pointer {pos:?}");
+ self.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32);
+ }
+ None => log::warn!("move_request: surface has no tracked window id"),
+ }
+ }
+
+ fn resize_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial, edges: xdg_toplevel::ResizeEdge) {
+ let Some(edge) = (match edges {
+ xdg_toplevel::ResizeEdge::Top => Some(srdwm_core::ResizeEdge::Top),
+ xdg_toplevel::ResizeEdge::Bottom => Some(srdwm_core::ResizeEdge::Bottom),
+ xdg_toplevel::ResizeEdge::Left => Some(srdwm_core::ResizeEdge::Left),
+ xdg_toplevel::ResizeEdge::Right => Some(srdwm_core::ResizeEdge::Right),
+ xdg_toplevel::ResizeEdge::TopLeft => Some(srdwm_core::ResizeEdge::TopLeft),
+ xdg_toplevel::ResizeEdge::TopRight => Some(srdwm_core::ResizeEdge::TopRight),
+ xdg_toplevel::ResizeEdge::BottomLeft => Some(srdwm_core::ResizeEdge::BottomLeft),
+ xdg_toplevel::ResizeEdge::BottomRight => Some(srdwm_core::ResizeEdge::BottomRight),
+ // `None` is a valid protocol value (the client leaves the edge
+ // unspecified) but `WindowManager::start_resize` needs one --
+ // there's nothing sensible to default it to that wouldn't be a
+ // guess, so this is a no-op rather than picking one.
+ _ => None,
+ }) else {
+ return;
+ };
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ let pos = crate::input::last_pointer_pos(self);
+ self.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32);
+ }
+ }
+
+ /// `maximize_request`/`unmaximize_request`/`fullscreen_request`/
+ /// `unfullscreen_request`/`minimize_request` were all still smithay's
+ /// default no-op (or configure-only) implementations - found
+ /// investigating the `toggle_fullscreen` decoration bug above, by
+ /// checking what else routes through the same `WindowManager` calls
+ /// the titlebar-button click handlers in `input.rs` already use.
+ /// These five are the *client-initiated* equivalent of those clicks: a
+ /// client's own window-menu "Maximize", pressing F11, an HTML5 video
+ /// going fullscreen, or (for a client that negotiated client-side
+ /// decoration and draws its own titlebar, like Firefox) that titlebar's
+ /// own maximize button - all ask the compositor to actually perform
+ /// the state change via these requests rather than the compositor
+ /// noticing on its own. Left unimplemented, every one of them was a
+ /// silent no-op: the client's button did nothing, with no error and
+ /// nothing to suggest why, from any app that relies on this instead of
+ /// (or in addition to) a compositor-side keybinding.
+ fn maximize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ fn unmaximize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ /// `_output` (the client's requested target output) is ignored --
+ /// single-seat, and every other fullscreen entry point (the titlebar
+ /// button, `srd.window.fullscreen()`) already fullscreens on whatever
+ /// monitor the window is already on, so this matches that instead of
+ /// introducing an output-aware fullscreen path only this one request
+ /// would use.
+ fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<WlOutput>) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if !self.wm.borrow().is_fullscreen(id) {
+ // `redraw_decoration_buffer` first, same reason
+ // `set_decorated_from_mode` calls it before `sync_geometry`:
+ // fullscreen also flips `Window.decorated`, and dropping
+ // the decoration needs the buffer actually removed, not
+ // just left stale for `sync_geometry`'s own resize-only
+ // redraw check to skip.
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if self.wm.borrow().is_fullscreen(id) {
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ /// No `send_configure` here, matching the pointer-driven
+ /// `TitlebarHit::Minimize` handler in `input.rs`: minimizing doesn't
+ /// change the window's own size, only whether it's currently shown, so
+ /// there's nothing new to tell the client about its own geometry.
+ fn minimize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ self.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+
+ /// Was a bare no-op - no `send_configure` at all. Per xdg-shell,
+ /// `xdg_surface.configure` is required before a popup's first commit;
+ /// real toolkits (confirmed live: GTK4's Wayland backend) block that
+ /// commit in a synchronous roundtrip waiting for it, so every popup
+ /// hung its client forever. GTK4 implements tooltips *and*
+ /// `Gtk.Popover` as `xdg_popup`, so this fired on hovering almost any
+ /// widget with a tooltip - confirmed by a peer session's gdb backtrace
+ /// (blocked in `wl_display_dispatch_queue` under `gtk_widget_show`)
+ /// after AGS wedged.
+ ///
+ /// Geometry is `positioner.get_geometry()` un-constrained - no
+ /// on-screen clamping yet (`PositionerState::get_unconstrained_geometry`
+ /// needs a target rect in the parent's surface-local space, which is a
+ /// real follow-up, not this fix); an occasional popup placed near a
+ /// screen edge may render partly off it, which is cosmetic, not a hang.
+ fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) {
+ // Temporary: live report is that Nemo's right-click context menu
+ // never appears at all (not mispositioned - entirely invisible),
+ // while the exact same xdg_popup mechanism works for Firefox. Logs
+ // the unconstrained geometry this popup gets so a live repro tells
+ // us whether it's landing off-screen/degenerate (the known gap this
+ // function's own doc comment already flags) or something else
+ // entirely. Remove once resolved.
+ let geom = positioner.get_geometry();
+ let parent = surface.get_parent_surface();
+ log::warn!("POPUP-GEOM-DIAG geometry={geom:?} parent={:?}", parent.as_ref().map(|s| s.id()));
+ surface.with_pending_state(|state| {
+ state.geometry = geom;
+ state.positioner = positioner;
+ });
+ if surface.send_configure().is_err() {
+ log::warn!("POPUP-GEOM-DIAG send_configure failed");
+ return;
+ }
+ let _ = self.popups.track_popup(smithay::desktop::PopupKind::Xdg(surface));
+ }
+
+ /// Implicit grab + dismiss-on-outside-click. Previously believed
+ /// blocked on `CompState`'s `SeatHandler` associated types not
+ /// satisfying `PopupManager::grab_popup`'s `WaylandFocus +
+ /// From<PopupKind>` bound - rechecked while implementing
+ /// `move_request`/`resize_request` (same trait, adjacent methods) and
+ /// it turns out they already do: `KeyboardFocus`/`PointerFocus` are
+ /// both plain `WlSurface`, smithay provides `impl From<PopupKind> for
+ /// WlSurface` itself, and `WlSurface: From<WlSurface>` trivially. No
+ /// blocker ever existed by the time of this pass; the bound just
+ /// hadn't been rechecked since being noted as unmet.
+ ///
+ /// `self.seat.clone()` rather than resolving `_seat` (the client's
+ /// `wl_seat` resource) via `Seat::from_resource` - this compositor
+ /// only ever has the one seat, matching how `move_request`/
+ /// `resize_request` already ignore the same parameter.
+ fn grab(&mut self, surface: PopupSurface, _seat: wl_seat::WlSeat, serial: Serial) {
+ let popup = PopupKind::Xdg(surface);
+ let Ok(root) = find_popup_root_surface(&popup) else {
+ log::warn!("POPUP-GRAB-DIAG find_popup_root_surface failed");
+ return;
+ };
+ let seat = self.seat.clone();
+ let grab = match self.popups.grab_popup(root, popup, &seat, serial) {
+ Ok(g) => g,
+ Err(e) => {
+ log::warn!("POPUP-GRAB-DIAG grab_popup failed: {e:?}");
+ return;
+ }
+ };
+ log::warn!("POPUP-GRAB-DIAG grab established, has_pointer={} has_keyboard={}", seat.get_pointer().is_some(), seat.get_keyboard().is_some());
+ if let Some(keyboard) = seat.get_keyboard() {
+ keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial);
+ }
+ if let Some(pointer) = seat.get_pointer() {
+ pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep);
+ }
+ }
+
+ fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) {
+ surface.with_pending_state(|state| {
+ state.geometry = positioner.get_geometry();
+ state.positioner = positioner;
+ });
+ surface.send_repositioned(token);
+ }
+
+ fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
+ self.remove_window(surface.wl_surface());
+ }
+}
diff --git a/crates/wayland/src/rounded_corners_pixman.rs b/crates/wayland/src/rounded_corners_pixman.rs
index 1879b9e..3a712f9 100644
--- a/crates/wayland/src/rounded_corners_pixman.rs
+++ b/crates/wayland/src/rounded_corners_pixman.rs
@@ -5,122 +5,131 @@
//! mask is a hardcoded flat alpha, and its destination image is private to
//! smithay's own module - no public hook for a custom mask picture).
//!
-//! The technique here instead bakes the mask into a *copy* of the client's
-//! own pixel data before it ever reaches the normal compositing path: read
-//! the surface's committed `wl_shm` buffer, punch premultiplied-alpha holes
-//! (this codebase's existing BGRA convention - see `decoration::
-//! shadow_bitmap`'s doc comment) into the four corner regions, and hand the
-//! result to `MemoryRenderBuffer` - the exact same type and render-element
-//! path already used for the titlebar/border/shadow bitmaps. Rendering it
-//! through the ordinary unmasked `render_texture_from_to` is what makes the
-//! corners actually disappear: a premultiplied-zero source pixel there
-//! contributes nothing, leaving whatever was already drawn underneath (the
-//! desktop, or another window) showing through - a real cutout, not a
-//! flat-colour patch.
+//! The technique here: render the window's *entire* surface tree (root
+//! plus every subsurface, exactly what the ordinary unmasked path already
+//! draws) into a private off-screen buffer, read that back as plain BGRA8
+//! bytes, and punch the four corner holes into *those* - the composited
+//! result, not any one client buffer - before handing it to
+//! `MemoryRenderBuffer`, the same type and render-element path already
+//! used for the titlebar/border/shadow bitmaps.
//!
-//! Deliberately narrow scope, same as the GLES version: only a window's
-//! *main* surface (no subsurfaces), and only the two common `wl_shm`
-//! formats this compositor's own bitmaps already use (`Argb8888`/
-//! `Xrgb8888`) - anything else, a non-`wl_shm` buffer (dmabuf, a GL
-//! client), or a non-identity buffer transform falls back to `None`, which
-//! the caller treats as "render this window's content unrounded" rather
-//! than an error.
+//! A previous version of this instead tried to identify *which one*
+//! surface in the tree held "the real content" (a root-plus-one-child
+//! GTK4/WebRender pattern, confirmed live against Firefox and Chrome) and
+//! masked that single client buffer directly, skipping everything else in
+//! the tree. That was cheaper - no extra render pass - but structurally
+//! fragile: it assumed the *rest* of the tree (whatever the chosen surface
+//! didn't cover) was always invisible padding, true for Chrome's own
+//! shadow-margin inset but false for Firefox, whose tab strip/title row is
+//! painted on the *root* surface, outside its own content child. The
+//! moment that surface-picking heuristic got permissive enough to actually
+//! mask Firefox's real, common case, it started *silently deleting
+//! Firefox's own tab strip* - reported live as "Firefox's titlebar turned
+//! invisible", confirmed by toggling `general.rounded_corners` off, which
+//! brought it straight back. Rendering the whole tree and masking the
+//! *output* instead of guessing which *input* is real sidesteps the whole
+//! question - the same reason a GPU shader-based compositor (niri,
+//! cosmic-comp) never has this class of bug at all: by the time its own
+//! shader runs, the subsurface tree is already flattened into one texture,
+//! so there is nothing left to misidentify.
+//!
+//! Only `wl_shm`/dmabuf-agnostic now - unlike the old per-buffer read,
+//! this never touches a client's own buffer format at all, only the
+//! renderer's own composited output, so the format/transform restrictions
+//! the previous version needed (`Argb8888`/`Xrgb8888` only, no dmabuf
+//! without a dedicated read path, `Transform::Normal` only) no longer
+//! apply - whatever the renderer can already draw (which is everything it
+//! draws for the ordinary unmasked path too), this can mask.
//!
//! Cost, and why this stays default-off on this backend (`general.
//! rounded_corners`, see `WindowManager::rounded_corners_enabled`'s doc
-//! comment): unlike the GLES shader, which the GPU evaluates once per pixel
-//! at zero extra CPU cost, this masks a full copy of the surface's pixel
-//! data on the CPU. The mask math itself only touches the four small
-//! corner boxes, but producing a tightly-packed buffer `MemoryRenderBuffer::
-//! from_slice` accepts (it asserts a `width * 4` stride; a client's own SHM
-//! stride is often larger, padded for alignment) means copying the whole
-//! buffer row by row regardless. The caller is expected to cache the
-//! result and only call this again when the surface's content has actually
-//! changed (see `CompState::content_epoch`), so the real per-frame cost for
-//! idle/static windows is nothing - but a constantly-repainting client
-//! (video, a terminal under heavy scrollback) pays this on every commit for
-//! as long the feature stays on, which is exactly the untested-on-real-
-//! hardware cost the opt-in default exists to avoid forcing on anyone.
+//! comment): a full extra off-screen render pass (allocate a buffer, draw
+//! the tree into it, read the result back) on every real content change,
+//! not just a raw memory copy the old approach needed - strictly more
+//! expensive per rebuild than before, though still gated by the same
+//! `CompState::content_epoch` cache as before, so an idle/static window
+//! still costs nothing per frame, only per genuine repaint.
use crate::rounded_corners::RoundedCorners;
use smithay::backend::allocator::Fourcc;
-use smithay::backend::renderer::element::memory::MemoryRenderBuffer;
-use smithay::backend::renderer::utils::{with_renderer_surface_state, RendererSurfaceState};
-use smithay::reexports::wayland_server::protocol::wl_shm;
+use smithay::backend::renderer::damage::OutputDamageTracker;
+use smithay::backend::renderer::element::surface::render_elements_from_surface_tree;
+use smithay::backend::renderer::element::Kind;
+use smithay::backend::renderer::pixman::PixmanRenderer;
+use smithay::backend::renderer::{Bind, ExportMem, Offscreen};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
-use smithay::utils::Transform;
-use smithay::wayland::compositor::get_children;
-use smithay::wayland::shm::{with_buffer_contents, BufferData};
+use smithay::utils::{Buffer as BufferCoord, Rectangle, Transform};
-/// Builds a rounded-corner-masked copy of `surface`'s own committed content,
-/// or `None` if that isn't possible right now - see this module's doc
-/// comment for every case that falls back rather than erroring. `radius` is
-/// in the same logical-pixel units as `decoration::CORNER_RADIUS`; scaled
-/// up to buffer pixels internally using the surface's own buffer scale.
-pub(crate) fn masked_content_buffer(surface: &WlSurface, radius: f32, corners: RoundedCorners) -> Option<MemoryRenderBuffer> {
- // The module doc comment above has always claimed "only a window's main
- // surface (no subsurfaces)... falls back to None" - but nothing here
- // actually checked that; this function read `surface`'s own buffer
- // unconditionally regardless of whether it had children. A window whose
- // real content is painted into a subsurface (a common GTK4/WebRender
- // pattern - confirmed live: Firefox does this) has its own root
- // surface holding only a blank/background buffer, so masking succeeded
- // and produced a buffer, just the wrong one - the actual page content
- // in the child subsurface was never read at all, and the window
- // rendered as blank with rounded corners on instead of falling back to
- // the unmasked path (`surface_content_elements`), which does walk the
- // full surface tree and shows real content correctly.
- if !get_children(surface).is_empty() {
- return None;
- }
- let (buffer, scale, transform) = with_renderer_surface_state(surface, |state: &mut RendererSurfaceState| {
- let buffer = state.buffer()?.clone();
- Some((buffer, state.buffer_scale(), state.buffer_transform()))
- })??;
- // A rotated/flipped buffer would need the mask rotated with it; not
- // worth the extra math for a cosmetic, already-narrow-scope pass.
- if transform != Transform::Normal {
+/// Renders `surface`'s whole subsurface tree into a private `size`-sized
+/// off-screen buffer - `loc` is the tree's own root-surface-relative
+/// origin to render at, exactly like `render_elements_from_surface_tree`'s
+/// own `location` parameter elsewhere in this codebase (`udev/capture.rs`);
+/// the caller passes the *negated* `content_offset`
+/// (`dwindow.geometry().loc`, the client's own declared shadow-margin
+/// inset) so the buffer's own `(0, 0)` lands exactly on the window's real
+/// visible content top-left, the same correction every other render path
+/// in this compositor already applies (see `udev/render.rs`'s own `pos`
+/// computation) - then punches the four rounded-corner holes into the
+/// result. Returns tightly-packed BGRA8 bytes (`size.0 * size.1 * 4`),
+/// ready for `MemoryRenderBuffer::from_slice`, or `None` if the off-screen
+/// render itself failed (a genuine renderer error, not "this window isn't
+/// shaped right for masking" - there is no such restriction anymore).
+///
+/// `radius` is already in the same units as `size` (this compositor's
+/// outputs are always scale `1.0`, per `WindowManager::rounded_corners_
+/// enabled`'s own doc comment, so there is no separate buffer-scale
+/// factor to fold in here the way the old per-client-buffer read needed).
+pub(crate) fn masked_content_buffer(renderer: &mut PixmanRenderer, surface: &WlSurface, loc: (i32, i32), size: (i32, i32), radius: f32, corners: RoundedCorners) -> Option<Vec<u8>> {
+ let (w, h) = size;
+ if w <= 0 || h <= 0 {
return None;
}
- let radius_px = radius * scale as f32;
-
- let (data, w, h) = with_buffer_contents(&buffer, move |ptr, len, data: BufferData| -> Option<(Vec<u8>, i32, i32)> {
- if !matches!(data.format, wl_shm::Format::Argb8888 | wl_shm::Format::Xrgb8888) {
+ // Transparent clear (not opaque black, unlike `udev/capture.rs`'s own
+ // off-screen render): this buffer holds only the window's own content,
+ // with nothing behind it to composite against yet - any area the
+ // surface tree doesn't actually draw into (a subsurface smaller than
+ // its own declared geometry, say) needs to stay real, punch-through
+ // transparency so the border/desktop already drawn underneath on the
+ // real output shows through there, not a solid black patch.
+ let elements = render_elements_from_surface_tree::<_, crate::elements::OverlayElement<PixmanRenderer>>(renderer, surface, loc, 1.0, 1.0, Kind::Unspecified);
+ let mut target = match renderer.create_buffer(Fourcc::Argb8888, (w, h).into()) {
+ Ok(t) => t,
+ Err(e) => {
+ log::debug!("rounded_corners_pixman: masked_content_buffer: create_buffer failed ({e:?}) - giving up unmasked");
return None;
}
- let (w, h, stride, offset) = (data.width, data.height, data.stride, data.offset);
- if w <= 0 || h <= 0 || stride <= 0 || offset < 0 {
+ };
+ let mut framebuffer = match renderer.bind(&mut target) {
+ Ok(fb) => fb,
+ Err(e) => {
+ log::debug!("rounded_corners_pixman: masked_content_buffer: bind failed ({e:?}) - giving up unmasked");
return None;
}
- let needed = offset as usize + stride as usize * h as usize;
- if needed > len {
+ };
+ let mut tracker = OutputDamageTracker::new((w, h), 1.0, Transform::Normal);
+ if let Err(e) = tracker.render_output(renderer, &mut framebuffer, 0, &elements, [0.0, 0.0, 0.0, 0.0]) {
+ log::debug!("rounded_corners_pixman: masked_content_buffer: render_output failed ({e:?}) - giving up unmasked");
+ return None;
+ }
+ let region: Rectangle<i32, BufferCoord> = Rectangle::new((0, 0).into(), (w, h).into());
+ let mapping = match renderer.copy_framebuffer(&framebuffer, region, Fourcc::Argb8888) {
+ Ok(m) => m,
+ Err(e) => {
+ log::debug!("rounded_corners_pixman: masked_content_buffer: copy_framebuffer failed ({e:?}) - giving up unmasked");
return None;
}
- // SAFETY: `pool.with_data` (inside `with_buffer_contents`) already
- // validated `ptr`/`len` cover the whole pool; `needed` above
- // re-checks this buffer's own slice sits inside that before a
- // single byte is read.
- let src = unsafe { std::slice::from_raw_parts(ptr.add(offset as usize), stride as usize * h as usize) };
-
- // Repack into a tight `width * 4` stride: `MemoryRenderBuffer::
- // from_slice` computes its own stride from `width` alone and
- // asserts the data matches it, so the source's (often padded) SHM
- // stride can't be handed through as-is.
- let row_bytes = w as usize * 4;
- let mut out = vec![0u8; row_bytes * h as usize];
- for y in 0..h as usize {
- let src_row = &src[y * stride as usize..y * stride as usize + row_bytes];
- out[y * row_bytes..(y + 1) * row_bytes].copy_from_slice(src_row);
+ };
+ let pixels = match renderer.map_texture(&mapping) {
+ Ok(p) => p,
+ Err(e) => {
+ log::debug!("rounded_corners_pixman: masked_content_buffer: map_texture failed ({e:?}) - giving up unmasked");
+ return None;
}
-
- let radius_px = radius_px.min(w as f32 / 2.0).min(h as f32 / 2.0);
- apply_corner_mask(&mut out, w, h, row_bytes as i32, radius_px, corners);
- Some((out, w, h))
- })
- .ok()
- .flatten()?;
-
- Some(MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (w, h), scale, Transform::Normal, None))
+ };
+ let mut out = pixels.to_vec();
+ let radius_px = radius.min(w as f32 / 2.0).min(h as f32 / 2.0);
+ apply_corner_mask(&mut out, w, h, w * 4, radius_px, corners);
+ Some(out)
}
/// Zeroes (fading over ~2px, matching `rounded_corners::FRAGMENT_SHADER`'s
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.
///
diff --git a/crates/wayland/src/udev/capture.rs b/crates/wayland/src/udev/capture.rs
index bcf2aa4..fb4c22f 100644
--- a/crates/wayland/src/udev/capture.rs
+++ b/crates/wayland/src/udev/capture.rs
@@ -8,22 +8,32 @@
//! workspace that, most of the time, is *not* the one presented.
//!
//! Deliberately simple, not a small reimplementation of
-//! `render_udev_frame`: only window content is drawn, no borders,
-//! shadows, titlebars, cursor or layer-shell surfaces - every consumer
-//! this was built for (a workspace-switcher tile) draws those tiny, where
-//! that detail is imperceptible, and skipping them keeps this from needing
-//! to duplicate that function's animation/occlusion bookkeeping. Always
-//! renders at the target monitor's native resolution and downscales
-//! afterward if a smaller size was requested, rather than trying to get
-//! smithay's fractional-output-scale rendering path exactly right for a
-//! target with no real `Output` behind it.
+//! `render_udev_frame`: no borders, shadows, titlebars or cursor --
+//! every consumer this was built for (a workspace-switcher tile) draws
+//! those tiny, where that detail is imperceptible, and skipping them
+//! keeps this from needing to duplicate that function's animation/
+//! occlusion bookkeeping. The background/bottom layer-shell surfaces
+//! (the wallpaper) *are* included, unlike the rest of that list - a
+//! capture with no windows on it and no wallpaper either is
+//! indistinguishable from broken, and was reported live as exactly that:
+//! "why does current workspace show black background" once measured
+//! against a real screenshot of the same moment (mean luminance ~0.5 vs.
+//! this capture's own ~0.03, i.e. genuinely near-black, not just "looks
+//! dark on this monitor"). An inactive workspace with literally no
+//! windows placed on it rendered *exactly* black (mean and variance both
+//! zero) for the same reason - there was nothing else in the frame at
+//! all to show. Always renders at the target monitor's native resolution
+//! and downscales afterward if a smaller size was requested, rather than
+//! trying to get smithay's fractional-output-scale rendering path
+//! exactly right for a target with no real `Output` behind it.
use super::*;
use smithay::backend::allocator::Fourcc;
-use smithay::backend::renderer::element::surface::{render_elements_from_surface_tree, WaylandSurfaceRenderElement};
+use smithay::backend::renderer::element::surface::render_elements_from_surface_tree;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::{Bind, ExportMem, Offscreen};
use smithay::utils::{Buffer as BufferCoord, Transform};
+use smithay::wayland::shell::wlr_layer::Layer;
impl CompState {
/// Services every capture request queued since the last poll. Takes
@@ -56,8 +66,8 @@ impl CompState {
}
let ids = self.wm.borrow().window_ids_on_workspace_front_to_back(req.workspace);
- let mut elements: Vec<WaylandSurfaceRenderElement<PixmanRenderer>> = Vec::new();
let Some(udev) = self.udev.as_mut() else { return Err("no udev backend".to_string()) };
+ let mut elements: Vec<crate::elements::OverlayElement<PixmanRenderer>> = Vec::new();
for id in ids {
let Some(w) = self.id_to_window.get(&id) else { continue };
let Some(surface) = crate::input::dwindow_wl_surface(w) else { continue };
@@ -68,7 +78,26 @@ impl CompState {
// a gap in the capture too.
let content_offset = w.geometry().loc;
let loc = (geom.x - origin.0 - content_offset.x, geom.y - origin.1 - content_offset.y);
- elements.extend(render_elements_from_surface_tree(&mut udev.renderer, &surface, loc, 1.0, 1.0, Kind::Unspecified));
+ elements.extend(render_elements_from_surface_tree::<_, crate::elements::OverlayElement<PixmanRenderer>>(
+ &mut udev.renderer,
+ &surface,
+ loc,
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ ));
+ }
+ // Background/bottom layer-shell (the wallpaper) last - bottommost,
+ // matching `render_udev_frame`'s own ordering convention (see that
+ // function's matching comment). The real output behind whichever
+ // monitor `origin`/`native` came from, matched by location; missing
+ // entirely (an output that vanished between resolving `origin`
+ // above and here, a narrow race) just means no wallpaper in this
+ // one capture, not a hard failure - windows above still render.
+ if let Some(head) = udev.heads.iter().find(|h| h.location == Point::from(origin)) {
+ elements.extend(crate::elements::output_layer_elements(&mut udev.renderer, &head.output, |layer| {
+ matches!(layer, Layer::Background | Layer::Bottom)
+ }));
}
let (nw, nh) = (native.0 as i32, native.1 as i32);
diff --git a/crates/wayland/src/udev/drm.rs b/crates/wayland/src/udev/drm.rs
index 667747b..ff25966 100644
--- a/crates/wayland/src/udev/drm.rs
+++ b/crates/wayland/src/udev/drm.rs
@@ -12,12 +12,22 @@ fn mode_refresh_mhz(mode: &DrmMode) -> i32 {
/// Brings one connector up: allocates its scanout buffers, sets the mode,
/// and creates the `wl_output` global. Shared by startup and hotplug so a
/// monitor plugged in later is set up exactly like one present at boot.
+///
+/// `scale` is `srd.monitor.scale(name, ...)`'s stored value for this
+/// connector, if any - an explicit override always wins. `None` no
+/// longer means "always 1.0": it falls through to `srdwm_core::monitor::
+/// auto_scale_for`, computed fresh from this connector's own real EDID
+/// physical size and resolution, so a physically large, low-density
+/// monitor gets a sensible scale with no per-connector-name config
+/// needed at all.
pub(crate) fn bring_up_head(
card: &Card,
dh: &DisplayHandle,
probe: &ConnectorProbe,
crtc: crtc::Handle,
x_offset: i32,
+ logical_x: i32,
+ scale: Option<f64>,
) -> PlatformResult<(UdevHead, crate::state::OutputEntry)> {
let (width, height) = probe.mode.size();
let (width, height) = (width as i32, height as i32);
@@ -32,7 +42,8 @@ pub(crate) fn bring_up_head(
// Physical size in millimeters comes straight from EDID via the
// connector, not the hardcoded (0, 0) this used to be - some clients
// compute their own effective DPI from it (independently of the
- // compositor's own scale factor, which srdwm always reports as 1), so
+ // compositor's own scale factor, which defaults to 1 unless `srd.
+ // monitor.scale` overrides it for this connector), so
// reporting "no physical size at all" was live, wrong data reaching
// every client, not just an unfilled-in placeholder.
let (phys_w, phys_h) = probe.info.size().unwrap_or((0, 0));
@@ -42,7 +53,28 @@ pub(crate) fn bring_up_head(
PhysicalProperties { size: physical_mm.into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() },
);
let mode = OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) };
- output.change_current_state(Some(mode), Some(Transform::Normal), None, Some((x_offset, 0).into()));
+ let resolved_scale = scale.unwrap_or_else(|| srdwm_core::monitor::auto_scale_for(physical_mm, (width, height)));
+ // `x_offset` is physical (the caller accumulates it from real head
+ // widths - see `UdevHead::location`'s own doc comment for why that's
+ // the space this compositor tracks output position in internally),
+ // but `change_current_state`'s own position parameter is a real
+ // Wayland-protocol value and `wl_output`/`xdg_output` always report
+ // position to clients in logical points - so it needs the caller's
+ // own *separately*-accumulated `logical_x`, not a value derived from
+ // `x_offset` and this head's own scale alone. Dividing `x_offset` by
+ // just this head's own `resolved_scale` (what this used to do) is only
+ // correct for the first head in a layout, or when every head shares
+ // the same scale - for any later head following one with a
+ // *different* scale, this head's own scale has nothing to do with how
+ // much logical space the *previous* heads actually occupy, so it
+ // computed the wrong logical position for anything past the first
+ // output. Reported live (measured from inside GTK, not inferred) as
+ // two monitors' logical rectangles overlapping by a few hundred
+ // pixels whenever one had a non-1.0 scale - ambiguous "which monitor
+ // is this point on" answers, and hit-testing/screenshots landing on
+ // the wrong output in the overlap band. See `platform.rs`'s startup
+ // loop for how `logical_x` is actually accumulated correctly.
+ output.change_current_state(Some(mode), Some(Transform::Normal), Some(smithay::output::Scale::Fractional(resolved_scale)), Some((logical_x, 0).into()));
output.set_preferred(mode);
let global = output.create_global::<CompState>(dh);
@@ -56,9 +88,11 @@ pub(crate) fn bring_up_head(
buffers,
front: 0,
flip_pending: false,
+ flip_pending_since: Instant::now(),
ages: [0, 0],
location,
size: (width, height),
+ mode: probe.mode,
flip_retry_after: None,
};
Ok((head, crate::state::OutputEntry { output, location }))
@@ -88,7 +122,16 @@ pub(crate) fn probe_connected(card: &Card) -> PlatformResult<Vec<ConnectorProbe>
if info.state() != connector::State::Connected {
continue;
}
- let name = format!("{:?}-{}", info.interface(), info.interface_id());
+ // `info.interface()`'s `Debug` output is Rust's own enum variant
+ // name (`HDMIA`, `EmbeddedDisplayPort`) - neither string exists
+ // anywhere else. The kernel, `ddcutil`, `/sys/class/drm`, and any
+ // config the user already has for another compositor all use the
+ // strings in `Interface::as_str()` (`HDMI-A`, `eDP`, and so on --
+ // taken directly from the kernel's own `drm_connector_enum_list`).
+ // Reported live: `srd monitors` showed `HDMIA-1`, a name that
+ // matched nothing, while `/sys/class/drm` and `ddcutil detect`
+ // both said `HDMI-A-1` for the same physical connector.
+ let name = format!("{}-{}", info.interface().as_str(), info.interface_id());
// Prefer the mode the display advertises as PREFERRED (its native
// resolution) rather than whatever happens to be listed first --
// the list order is not guaranteed, and picking wrong means running
diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs
index 9a6bf49..ae0bd7f 100644
--- a/crates/wayland/src/udev/mod.rs
+++ b/crates/wayland/src/udev/mod.rs
@@ -33,13 +33,14 @@ use std::rc::Rc;
use std::time::{Duration, Instant};
use smithay::backend::input::{
- Axis, ButtonState as BackendButtonState, Event as InputEventTrait, GestureBeginEvent as BackendGestureBeginEvent,
+ AbsolutePositionEvent, Axis, ButtonState as BackendButtonState, Event as InputEventTrait, GestureBeginEvent as BackendGestureBeginEvent,
GestureEndEvent as BackendGestureEndEvent, GesturePinchUpdateEvent as BackendGesturePinchUpdateEvent, InputEvent,
PointerAxisEvent, PointerButtonEvent, PointerMotionEvent,
};
use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface};
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
+use smithay::backend::renderer::element::solid::SolidColorBuffer;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::pixman::PixmanRenderer;
use smithay::backend::renderer::{Bind, ImportDma};
@@ -128,6 +129,11 @@ pub(crate) struct UdevHead {
/// A flip is in flight; the next frame for this head waits for the DRM
/// page-flip event (matched by `crtc`) before starting.
pub(crate) flip_pending: bool,
+ /// When the current `flip_pending` was set - lets `render_udev_frame`
+ /// notice a page-flip event that never arrived (or arrived but matched
+ /// no head - see `FLIP_TIMEOUT`'s own doc comment) instead of waiting
+ /// on it forever. Meaningless while `flip_pending` is `false`.
+ pub(crate) flip_pending_since: Instant,
/// Per-buffer-slot age passed to `damage_tracker.render_output`: how
/// many *damage-producing* renders ago that exact buffer was last
/// brought fully up to date. 0 means "never rendered, contents
@@ -149,6 +155,16 @@ pub(crate) struct UdevHead {
/// Origin of this head in the global coordinate space.
pub(crate) location: Point<i32, Logical>,
pub(crate) size: (i32, i32),
+ /// The DRM mode this head was actually brought up with - kept so a VT-
+ /// switch resume can reassert the CRTC with its real connector and
+ /// mode (see `register_session_notifier`'s own `ActivateSession` arm),
+ /// rather than the empty connector list and `None` mode that call used
+ /// to pass, which does not reassert a CRTC at all - it is DRM/KMS's
+ /// own shape for *disabling* one. Confirmed live: switching back to
+ /// srdwm's VT after switching away left the screen black, with no
+ /// further VT switch (either direction) able to recover it, matching a
+ /// CRTC left disabled rather than restored.
+ pub(crate) mode: DrmMode,
/// Set when [`UdevHead::copy_and_flip`] fails; no new flip is attempted
/// for this head again until this deadline passes.
///
@@ -197,14 +213,112 @@ pub(crate) struct UdevState {
/// backend needed the session handle after startup, so it was never
/// retained anywhere before this.
pub(crate) session: LibSeatSession,
+ /// Connector names administratively disabled via `srd dispatch set
+ /// output enabled <name> false` - still physically connected (DRM
+ /// still reports/probes them), just deliberately not driven. Checked
+ /// by `reprobe_outputs`'s own "added" loop so an unrelated hotplug
+ /// event doesn't resurrect one of these the next time anything else
+ /// plugs or unplugs - without this, the very next `Changed` uevent
+ /// (any connector, not just this one) would see a disabled-but-still-
+ /// present connector as newly "added" (present in a fresh probe,
+ /// absent from `heads`, exactly the condition that branch already
+ /// uses to detect a real hotplug) and bring it straight back up.
+ pub(crate) disabled_connectors: std::collections::HashSet<String>,
+ /// `WorkspaceId` this backend last built `custom_elements` for --
+ /// compared against `WindowManager::current_workspace()` at the top of
+ /// every `render_udev_frame` call so a switch can force every head's
+ /// `ages` back to `[0, 0]` (see that call site's own comment for why).
+ /// `None` before the very first frame, which already renders fully
+ /// regardless (every head starts with `ages: [0, 0]` - see
+ /// `UdevHead`'s own field).
+ pub(crate) last_rendered_workspace: Option<srdwm_core::WorkspaceId>,
}
impl UdevState {
- /// Bounding box of every head, used to clamp pointer motion.
- fn bounds(&self) -> (f64, f64) {
- let w = self.heads.iter().map(|h| h.location.x + h.size.0).max().unwrap_or(0);
- let h = self.heads.iter().map(|h| h.location.y + h.size.1).max().unwrap_or(0);
- (w as f64, h as f64)
+ /// Bounding box of every head, used to clamp pointer motion --
+ /// `(min_x, min_y, max_x, max_y)`, not just a `(width, height)`
+ /// implicitly anchored at `(0, 0)` (what this used to return, and what
+ /// every call site clamped into with a hardcoded `0.0` floor). That
+ /// was only ever correct while every head's `location.x`/`location.y`
+ /// stayed `>= 0`, true for `reprobe_outputs`' own left-to-right hotplug
+ /// layout but not guaranteed once `set_output_position` exists: an
+ /// "extend left"/"extend above" arrangement (a real one, requested and
+ /// applied live by an AGS peer session's monitor-layout panel) places
+ /// the newly-added head at a *negative* `x`/`y` relative to whichever
+ /// one stayed at the origin. With the old `(0, w)` clamp, the pointer
+ /// could never actually cross into that negative-origin region at
+ /// all - reported live as "clicked it now I can't go to other
+ /// monitor at all" once such an arrangement was applied. The AGS
+ /// side has since started normalising every arrangement it sends so
+ /// the leftmost/topmost edge lands at `0` again, which works around
+ /// this from outside, but srdwm's own pointer clamp assuming an origin
+ /// no other part of this backend actually enforces is the real bug --
+ /// fixed here instead of just left for every future caller to avoid.
+ fn bounds(&self) -> (f64, f64, f64, f64) {
+ bounds_of(self.heads.iter().map(|h| (h.location.x, h.location.y, h.size.0, h.size.1)))
+ }
+}
+
+/// The actual arithmetic behind [`UdevState::bounds`], over plain
+/// `(x, y, width, height)` tuples rather than real `UdevHead`s - pulled
+/// out so it's testable without a real DRM/`Card` handle, which every
+/// `UdevHead` in this module otherwise needs to even construct.
+fn bounds_of(heads: impl Iterator<Item = (i32, i32, i32, i32)>) -> (f64, f64, f64, f64) {
+ let mut min_x = 0;
+ let mut min_y = 0;
+ let mut max_x = 0;
+ let mut max_y = 0;
+ let mut any = false;
+ for (x, y, w, h) in heads {
+ if !any {
+ min_x = x;
+ min_y = y;
+ any = true;
+ } else {
+ min_x = min_x.min(x);
+ min_y = min_y.min(y);
+ }
+ max_x = max_x.max(x + w);
+ max_y = max_y.max(y + h);
+ }
+ (min_x as f64, min_y as f64, max_x as f64, max_y as f64)
+}
+
+#[cfg(test)]
+mod bounds_tests {
+ use super::bounds_of;
+
+ #[test]
+ fn single_head_at_origin_matches_the_old_zero_anchored_behaviour() {
+ assert_eq!(bounds_of([(0, 0, 1920, 1080)].into_iter()), (0.0, 0.0, 1920.0, 1080.0));
+ }
+
+ #[test]
+ fn two_heads_left_to_right_from_origin() {
+ assert_eq!(bounds_of([(0, 0, 1920, 1080), (1920, 0, 1920, 1080)].into_iter()), (0.0, 0.0, 3840.0, 1080.0));
+ }
+
+ #[test]
+ fn negative_origin_head_is_reflected_in_min_not_clamped_to_zero() {
+ // The actual regression this exists for: an "extend left"
+ // arrangement places the new head at a negative x, and the old
+ // `(width, height)`-only version of this function (implicitly
+ // anchored at 0) made that head's own region completely
+ // unreachable by pointer motion - reported live as "clicked it
+ // now I can't go to other monitor at all".
+ let (min_x, min_y, max_x, max_y) = bounds_of([(0, 0, 1920, 1080), (-1920, 0, 1920, 1080)].into_iter());
+ assert_eq!((min_x, min_y, max_x, max_y), (-1920.0, 0.0, 1920.0, 1080.0));
+ }
+
+ #[test]
+ fn negative_origin_above_is_reflected_in_min_y() {
+ let (min_x, min_y, max_x, max_y) = bounds_of([(0, 0, 1920, 1080), (0, -1080, 1920, 1080)].into_iter());
+ assert_eq!((min_x, min_y, max_x, max_y), (0.0, -1080.0, 1920.0, 1080.0));
+ }
+
+ #[test]
+ fn no_heads_at_all_is_a_degenerate_zero_sized_box_not_a_panic() {
+ assert_eq!(bounds_of(std::iter::empty()), (0.0, 0.0, 0.0, 0.0));
}
}
@@ -228,8 +342,33 @@ impl UdevHead {
/// buffer (software rendering writes into its own owned image, not the
/// scanout memory directly, to avoid tying that image's lifetime to an
/// mmap - see this module's docs) and flips to it.
- fn copy_and_flip(&mut self, card: &Card, back: usize) -> std::io::Result<()> {
- let (src_stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height());
+ /// `damage` is the exact set of rects `render_output` just re-rendered
+ /// into `self.buffers[back].image` - an empty slice means "copy
+ /// everything" (the locked/lock-UI render paths don't bother computing
+ /// per-rect damage, so this is also the safe fallback for any caller
+ /// that can't cheaply produce real rects), otherwise only those rows'
+ /// column ranges are copied.
+ ///
+ /// Used to be an unconditional full-buffer copy regardless of how
+ /// little of the frame actually changed - `render_output`'s own
+ /// age-based damage tracking already leaves everything outside
+ /// `damage` untouched in `image` (correct: that buffer's untouched
+ /// pixels still match what was on screen `ages[back]` frames ago), so
+ /// `dumb` - this same buffer's DRM-mapped twin, previously brought up
+ /// to date by this exact function on that same past frame - is
+ /// already correct everywhere outside `damage` too. Copying the whole
+ /// buffer anyway meant a full `stride * height` memcpy on every single
+ /// presented frame, for content as small as a moved cursor or a
+ /// blinking terminal caret - confirmed as the largest per-frame CPU
+ /// cost on this software `PixmanRenderer` backend by a direct
+ /// comparison against niri's DRM-composited present path (which has no
+ /// equivalent copy step at all) and mutter's native backend (which
+ /// explicitly restricts its own swap to damaged regions,
+ /// `swap_buffers_with_damage`) - this is the same technique, adapted
+ /// to a raw byte copy instead of a GL/EGL damage extension.
+ fn copy_and_flip(&mut self, card: &Card, back: usize, damage: &[Rectangle<i32, Physical>]) -> std::io::Result<()> {
+ let (src_stride, height, width) =
+ (self.buffers[back].image.stride(), self.buffers[back].image.height(), self.buffers[back].image.width());
let byte_len = src_stride * height;
// SAFETY: `image` owns this memory and outlives the byte slice we
// construct from it here; we only read, and only for the duration
@@ -248,23 +387,142 @@ impl UdevHead {
let dst_stride = self.buffers[back].dumb.pitch() as usize;
{
let mut mapping = card.map_dumb_buffer(&mut self.buffers[back].dumb)?;
- let dst = mapping.as_mut();
- let row_len = src_stride.min(dst_stride);
- for row in 0..height {
- let s = row * src_stride;
- let d = row * dst_stride;
- if s + row_len > src.len() || d + row_len > dst.len() {
- break;
- }
- dst[d..d + row_len].copy_from_slice(&src[s..s + row_len]);
- }
+ copy_damaged_rows(src, mapping.as_mut(), src_stride, dst_stride, width, height, damage);
}
card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?;
self.flip_pending = true;
+ self.flip_pending_since = Instant::now();
Ok(())
}
}
+/// The row/column copy math behind [`DrmHead::copy_and_flip`], pulled out
+/// as a free function over plain slices so it's testable without a real
+/// `Card`/dumb buffer - everything else in that method needs live DRM
+/// state, this doesn't. `damage` empty means "copy every row in full"
+/// (`width`/`height` are pixels, `src_stride`/`dst_stride` bytes); a
+/// non-empty `damage` copies only each rect's row/column span, clamped to
+/// the narrower of the two strides and to `width`/`height` the same way
+/// the full-copy path always has.
+fn copy_damaged_rows(src: &[u8], dst: &mut [u8], src_stride: usize, dst_stride: usize, width: usize, height: usize, damage: &[Rectangle<i32, Physical>]) {
+ let full_row_len = src_stride.min(dst_stride);
+ let copy_row = |dst: &mut [u8], row: usize, col_start_bytes: usize, col_len: usize| {
+ let s = row * src_stride + col_start_bytes;
+ let d = row * dst_stride + col_start_bytes;
+ let len = col_len.min(full_row_len.saturating_sub(col_start_bytes));
+ if len == 0 || s + len > src.len() || d + len > dst.len() {
+ return;
+ }
+ dst[d..d + len].copy_from_slice(&src[s..s + len]);
+ };
+ if damage.is_empty() {
+ for row in 0..height {
+ copy_row(dst, row, 0, full_row_len);
+ }
+ return;
+ }
+ const BPP: usize = 4; // Argb8888/Xrgb8888, same assumption every other raw-buffer path in this codebase makes.
+ for rect in damage {
+ let y0 = rect.loc.y.max(0) as usize;
+ let y1 = (rect.loc.y.saturating_add(rect.size.h).max(0) as usize).min(height);
+ let x0 = rect.loc.x.max(0) as usize;
+ let x1 = (rect.loc.x.saturating_add(rect.size.w).max(0) as usize).min(width);
+ if x1 <= x0 {
+ continue;
+ }
+ let (col_start_bytes, col_len) = (x0 * BPP, (x1 - x0) * BPP);
+ for row in y0..y1 {
+ copy_row(dst, row, col_start_bytes, col_len);
+ }
+ }
+}
+
+#[cfg(test)]
+mod copy_damaged_rows_tests {
+ use super::copy_damaged_rows;
+ use smithay::utils::{Physical, Point, Rectangle, Size};
+
+ fn rect(x: i32, y: i32, w: i32, h: i32) -> Rectangle<i32, Physical> {
+ Rectangle::new(Point::from((x, y)), Size::from((w, h)))
+ }
+
+ /// A tiny 4x3 BGRA canvas, one distinct byte value per pixel's blue
+ /// channel (row * width + col) so a wrong offset or a skipped pixel
+ /// shows up as the wrong number, not just "still zero".
+ fn make_src(width: usize, height: usize) -> Vec<u8> {
+ let mut buf = vec![0u8; width * height * 4];
+ for (i, px) in buf.chunks_exact_mut(4).enumerate() {
+ px[0] = i as u8;
+ px[3] = 255;
+ }
+ buf
+ }
+
+ #[test]
+ fn empty_damage_copies_every_row_in_full() {
+ let (w, h) = (4, 3);
+ let src = make_src(w, h);
+ let mut dst = vec![0u8; w * h * 4];
+ copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[]);
+ assert_eq!(dst, src);
+ }
+
+ #[test]
+ fn a_damage_rect_updates_only_its_own_pixels() {
+ let (w, h) = (4, 3);
+ let src = make_src(w, h);
+ let mut dst = vec![0u8; w * h * 4];
+ // Only the single pixel at (1, 1).
+ copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(1, 1, 1, 1)]);
+ let idx = (1 * w + 1) * 4;
+ assert_eq!(dst[idx], src[idx], "the damaged pixel must be copied");
+ assert_eq!(dst[0], 0, "a pixel outside the damage rect must stay untouched");
+ assert_eq!(dst[dst.len() - 4], 0, "the last row's pixel is also outside the rect and must stay untouched");
+ }
+
+ #[test]
+ fn a_full_width_row_rect_copies_that_row_only() {
+ let (w, h) = (4, 3);
+ let src = make_src(w, h);
+ let mut dst = vec![0u8; w * h * 4];
+ copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(0, 1, w as i32, 1)]);
+ let row1 = w * 4..w * 4 * 2;
+ assert_eq!(dst[row1.clone()], src[row1], "row 1 must be fully copied");
+ assert_eq!(&dst[..w * 4], &vec![0u8; w * 4][..], "row 0 must stay untouched");
+ assert_eq!(&dst[w * 4 * 2..], &vec![0u8; w * 4][..], "row 2 must stay untouched");
+ }
+
+ #[test]
+ fn a_rect_extending_past_the_buffer_is_clamped_not_panicking() {
+ let (w, h) = (4, 3);
+ let src = make_src(w, h);
+ let mut dst = vec![0u8; w * h * 4];
+ // Starts inside the buffer but both extends past its right/bottom
+ // edge and would run off a naive unclamped copy.
+ copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(2, 2, 100, 100)]);
+ let idx = (2 * w + 2) * 4;
+ assert_eq!(dst[idx], src[idx], "the in-bounds corner of an oversized rect must still be copied");
+ }
+
+ #[test]
+ fn a_wider_destination_stride_does_not_shear_rows() {
+ // Destination row padded 4 extra bytes past the source's own
+ // stride - the same "driver-padded dumb buffer pitch" case the
+ // full-copy path was already written to handle; damage-restricted
+ // copying must preserve that, not just the empty-damage fallback.
+ let (w, h) = (4, 3);
+ let src = make_src(w, h);
+ let dst_stride = w * 4 + 4;
+ let mut dst = vec![0u8; dst_stride * h];
+ copy_damaged_rows(&src, &mut dst, w * 4, dst_stride, w, h, &[rect(0, 0, w as i32, h as i32)]);
+ for row in 0..h {
+ let s = row * w * 4..row * w * 4 + w * 4;
+ let d = row * dst_stride..row * dst_stride + w * 4;
+ assert_eq!(dst[d], src[s], "row {row} must land at the destination's own stride, not the source's");
+ }
+ }
+}
+
mod capture;
mod drm;
mod outputs;
diff --git a/crates/wayland/src/udev/outputs.rs b/crates/wayland/src/udev/outputs.rs
index 6e282d6..c3291d8 100644
--- a/crates/wayland/src/udev/outputs.rs
+++ b/crates/wayland/src/udev/outputs.rs
@@ -2,6 +2,46 @@ use super::*;
use super::drm::{bring_up_head, pick_crtc, probe_connected};
impl CompState {
+ /// Applies whatever monitor layout `monitor_layout::load()` remembers
+ /// from a previous run, on top of the default left-to-right layout
+ /// every head was just brought up with. Call once, right after every
+ /// head exists but before the Wayland socket is bound - see the call
+ /// site in `platform.rs`'s `connect()` for why that ordering is the
+ /// entire point (no client, panel or otherwise, gets a chance to see
+ /// the un-restored arrangement, not even for one frame).
+ ///
+ /// A connector with no remembered entry (a monitor plugged in for the
+ /// first time, or a fresh install with no state file yet) is left
+ /// exactly where the default layout put it - this only ever narrows
+ /// toward a remembered position, never invents one.
+ pub(crate) fn restore_monitor_layout(&mut self) {
+ let remembered = crate::monitor_layout::load();
+ if remembered.is_empty() {
+ return;
+ }
+ // Disables first, deliberately: `disable_connector_by_name` ends
+ // with its own `relayout_outputs()` call, which recomputes every
+ // *remaining* head's position from the default left-to-right
+ // layout - doing that after a position restore below would just
+ // overwrite it again. Processing every disable up front means
+ // that default re-layout has already happened, once, before any
+ // remembered position gets applied on top of it.
+ for (name, entry) in &remembered {
+ if !entry.enabled {
+ self.disable_connector_by_name(name);
+ }
+ }
+ for (name, entry) in &remembered {
+ if !entry.enabled {
+ continue;
+ }
+ let Some(output) = self.udev.as_ref().and_then(|u| u.heads.iter().find(|h| &h.output.name() == name)).map(|h| h.output.clone()) else {
+ continue;
+ };
+ crate::output_management::apply_output_position(self, &output, (entry.x, entry.y).into());
+ }
+ }
+
/// Re-probes connectors after a hotplug and reconciles the head list.
///
/// Connectors that vanished have their head torn down (global removed,
@@ -23,17 +63,60 @@ impl CompState {
let present: Vec<connector::Handle> = probes.iter().map(|p| p.connector).collect();
let existing: Vec<connector::Handle> = udev.heads.iter().map(|h| h.connector).collect();
+ // A disabled connector that's genuinely gone from this fresh probe
+ // was actually unplugged, not just left administratively off --
+ // checked (and cleaned up) *before* the `gone.is_empty() &&
+ // added.is_empty()` early-out just below, since a disabled
+ // connector was never in `existing`/`heads` to begin with and so
+ // never affects either of those on its own; without this check
+ // running first, that early-out would fire and this cleanup would
+ // simply never happen for a hotplug event this narrow. See
+ // `MonitorInfo::enabled`'s own doc comment for why "off" and "not
+ // connected" have to be reported differently - this is what
+ // actually makes that transition happen.
+ let present_names: Vec<&str> = probes.iter().map(|p| p.name.as_str()).collect();
+ let unplugged_while_disabled: Vec<String> = udev.disabled_connectors.iter().filter(|name| !present_names.contains(&name.as_str())).cloned().collect();
+ if !unplugged_while_disabled.is_empty() {
+ let mut wm = self.wm.borrow_mut();
+ for name in &unplugged_while_disabled {
+ log::info!("udev: administratively-disabled output {name} was physically unplugged");
+ wm.clear_disabled_monitor(name);
+ }
+ }
+
let gone: Vec<connector::Handle> = existing.iter().copied().filter(|c| !present.contains(c)).collect();
let added: Vec<usize> = probes
.iter()
.enumerate()
- .filter(|(_, p)| !existing.contains(&p.connector))
+ // `!udev.disabled_connectors.contains(&p.name)`: without this,
+ // an administratively-disabled-but-still-connected output
+ // (`disable_connector_by_name`) looks identical to a genuinely
+ // new one here - present in a fresh probe, absent from
+ // `heads` - and this *unrelated* hotplug event (any
+ // connector, not just the disabled one) would bring it
+ // straight back up.
+ .filter(|(_, p)| !existing.contains(&p.connector) && !udev.disabled_connectors.contains(&p.name))
.map(|(i, _)| i)
.collect();
- if gone.is_empty() && added.is_empty() {
+ // `udev` (the outer immutable borrow) is done being read after
+ // this point, so `disabled_connectors` can be mutated now to drop
+ // whatever `unplugged_while_disabled` found - deferred this far
+ // specifically because the `added` filter just above still needed
+ // to read it first.
+ if !unplugged_while_disabled.is_empty() {
+ if let Some(udev) = self.udev.as_mut() {
+ udev.disabled_connectors.retain(|name| !unplugged_while_disabled.contains(name));
+ }
+ }
+ if gone.is_empty() && added.is_empty() && unplugged_while_disabled.is_empty() {
return; // a "changed" event that didn't change the connector set
}
- log::info!("udev: hotplug - {} output(s) removed, {} added", gone.len(), added.len());
+ log::info!(
+ "udev: hotplug - {} output(s) removed, {} added, {} disabled-and-unplugged",
+ gone.len(),
+ added.len(),
+ unplugged_while_disabled.len()
+ );
// ---- removals ----
for connector in &gone {
@@ -62,7 +145,8 @@ impl CompState {
continue;
};
// Placed at 0 for now; the re-layout below assigns real offsets.
- match bring_up_head(&card, &self.dh.clone(), probe, crtc, 0) {
+ let scale = self.wm.borrow().monitor_scale(&probe.name);
+ match bring_up_head(&card, &self.dh.clone(), probe, crtc, 0, 0, scale) {
Ok((head, entry)) => {
log::info!("udev: output {} connected ({}x{})", probe.name, head.size.0, head.size.1);
let monitor_id = self.outputs.len() as u32;
@@ -79,20 +163,215 @@ impl CompState {
}
}
+ // Safety net: never leave the session with zero live outputs.
+ // Real scenario, flagged live before it could actually happen:
+ // administratively disable the internal/laptop panel (`srd
+ // dispatch set output enabled ... false`), then physically unplug
+ // the one remaining external monitor - this same hotplug path
+ // handles the unplug correctly (the external head is removed
+ // above, same as any other disconnect), but without this, the
+ // internal panel stays administratively disabled forever after,
+ // leaving genuinely nothing to drive at all: no picture, and (a
+ // laptop having no other input device to fix it from) no way back
+ // in short of a restart. Re-enabling the most recently disabled
+ // connector that's still physically present - exactly
+ // `enable_connector_by_name`'s own normal path, just triggered by
+ // "we're about to have nothing" instead of an explicit request --
+ // trades the administrative disable for actually having a screen,
+ // which is the only reasonable choice once the alternative is a
+ // fully dark machine.
+ let no_live_heads = self.udev.as_ref().is_some_and(|u| u.heads.is_empty());
+ if no_live_heads {
+ let candidates: Vec<&drm::ConnectorProbe> =
+ self.udev.as_ref().map(|u| probes.iter().filter(|p| u.disabled_connectors.contains(&p.name)).collect()).unwrap_or_default();
+ // The internal/laptop panel specifically, if it's one of the
+ // candidates - `eDP`/`LVDS`/`DSI` are the real DRM connector-
+ // type prefixes an embedded display reports as, matching the
+ // exact scenario this exists for (disable the internal panel,
+ // then lose the external one it was standing in for). Falls
+ // back to whatever else is available rather than doing
+ // nothing, on the same "a screen is better than no screen"
+ // reasoning - an external monitor left administratively
+ // disabled is still a better fallback than a fully dark
+ // machine, even if it wasn't the specific one this was
+ // written for.
+ let fallback = candidates
+ .iter()
+ .find(|p| p.name.starts_with("eDP") || p.name.starts_with("LVDS") || p.name.starts_with("DSI"))
+ .or_else(|| candidates.first())
+ .map(|p| p.name.clone());
+ if let Some(name) = fallback {
+ log::warn!("udev: every output would otherwise be off - re-enabling {name} rather than leaving nothing to drive");
+ self.enable_connector_by_name(&name);
+ return;
+ }
+ }
+
+ self.relayout_outputs();
+ }
+
+ /// Administratively disables the output named `name` - the backend
+ /// half of `srd dispatch set output enabled <name> false`. Reuses
+ /// exactly the same removal steps `reprobe_outputs` already takes for
+ /// a real unplug just above (destroy the `wl_output` global, unmap
+ /// from `Space`, drop lock-surface tracking, free the DRM buffers via
+ /// `head.release`, rehome its windows via a `MonitorRemoved` event) --
+ /// the only difference is remembering the connector's *name*
+ /// afterward, in `UdevState::disabled_connectors`, so `reprobe_
+ /// outputs` won't bring it straight back on the next unrelated
+ /// hotplug, and so `enable_connector_by_name` can find it again later
+ /// without a real replug.
+ pub(crate) fn disable_connector_by_name(&mut self, name: &str) {
+ let Some(udev) = self.udev.as_mut() else { return };
+ let card = udev.card.clone();
+ let Some(index) = udev.heads.iter().position(|h| h.output.name() == name) else {
+ log::warn!("udev: set output enabled false: no connected output named {name}");
+ return;
+ };
+ // Snapshotted before removal, same computation `Platform::
+ // monitors()` itself uses - see `WindowManager::
+ // set_disabled_monitor`'s own doc comment for why `srd monitors`
+ // still wants this after the head is gone (a last-known rect to
+ // show, not a live one).
+ let head_ref = &udev.heads[index];
+ let zone = layer_map_for_output(&head_ref.output).non_exclusive_zone();
+ // `zone` is logical (scale-divided), `head_ref.location`/`size` are
+ // raw physical pixels - same unit mismatch `Platform::monitors()`
+ // itself had to be fixed for, and the same fix: scale `zone` back
+ // into physical pixels before combining. See that function's own
+ // doc comment for the live symptom this caused when left
+ // unconverted (a scaled output's reported geometry overlapping its
+ // neighbor's).
+ let scale = head_ref.output.current_scale().fractional_scale();
+ let zone_physical = |v: i32| (v as f64 * scale).round() as i32;
+ let usable_geometry = srdwm_core::Rect::new(
+ head_ref.location.x + zone_physical(zone.loc.x),
+ head_ref.location.y + zone_physical(zone.loc.y),
+ zone_physical(zone.size.w).max(0) as u32,
+ zone_physical(zone.size.h).max(0) as u32,
+ );
+ let full_geometry = srdwm_core::Rect::new(head_ref.location.x, head_ref.location.y, head_ref.size.0 as u32, head_ref.size.1 as u32);
+ let was_primary = index == 0;
+ let head = udev.heads.remove(index);
+ log::info!("udev: output {name} administratively disabled");
+ self.dh.remove_global::<CompState>(head.global.clone());
+ self.space.unmap_output(&head.output);
+ self.outputs.retain(|e| e.output != head.output);
+ self.lock.surfaces.remove(&head.output.name());
+ self.lock.presented.remove(&head.output.name());
+ head.release(&card);
+ self.pending.borrow_mut().push(CoreEvent::MonitorRemoved(index as u32));
+ if let Some(udev) = self.udev.as_mut() {
+ udev.disabled_connectors.insert(name.to_string());
+ }
+ self.wm.borrow_mut().set_disabled_monitor(name.to_string(), usable_geometry, full_geometry, was_primary);
+ self.relayout_outputs();
+ // Last-known physical position kept alongside `enabled: false` --
+ // re-enabling this same connector later (`enable_connector_by_name`
+ // below) restores it, rather than a disable silently discarding
+ // where it used to be.
+ crate::monitor_layout::save_output(name, crate::monitor_layout::PersistedOutput { x: full_geometry.x, y: full_geometry.y, enabled: false });
+ }
+
+ /// The other half of `disable_connector_by_name` - brings a
+ /// previously-disabled-but-still-connected output back up exactly the
+ /// way `reprobe_outputs` brings up a genuinely new one, since nothing
+ /// about the underlying hardware actually changed in between (the
+ /// connector was never really unplugged, just not driven).
+ pub(crate) fn enable_connector_by_name(&mut self, name: &str) {
+ let Some(udev) = self.udev.as_ref() else { return };
+ let card = udev.card.clone();
+ if !udev.disabled_connectors.contains(name) {
+ log::warn!("udev: set output enabled true: {name} isn't administratively disabled (already on, or never connected)");
+ return;
+ }
+ let probes = match probe_connected(&card) {
+ Ok(p) => p,
+ Err(e) => {
+ log::warn!("udev: re-enable probe for {name} failed: {e}");
+ return;
+ }
+ };
+ let Some(probe) = probes.iter().find(|p| p.name == name) else {
+ log::warn!("udev: set output enabled true: {name} is no longer physically connected");
+ if let Some(udev) = self.udev.as_mut() {
+ udev.disabled_connectors.remove(name);
+ }
+ // "Off" and "not connected" have to read differently to a
+ // listener (see `MonitorInfo::enabled`'s own doc comment) --
+ // this output is now the latter, so it stops being listed at
+ // all, same as a genuine unplug always has.
+ self.wm.borrow_mut().clear_disabled_monitor(name);
+ return;
+ };
+ let used: Vec<crtc::Handle> = udev.heads.iter().map(|h| h.crtc).collect();
+ let Some(crtc) = pick_crtc(&card, probe, &used) else {
+ log::warn!("udev: no free CRTC to re-enable {name}");
+ return;
+ };
+ // Placed at 0 for now; `relayout_outputs` below assigns real
+ // offsets, same as a genuine hotplug addition.
+ let scale = self.wm.borrow().monitor_scale(name);
+ match bring_up_head(&card, &self.dh.clone(), probe, crtc, 0, 0, scale) {
+ Ok((head, entry)) => {
+ log::info!("udev: output {name} re-enabled ({}x{})", head.size.0, head.size.1);
+ let monitor_id = self.outputs.len() as u32;
+ let geometry = srdwm_core::Rect::new(0, 0, head.size.0 as u32, head.size.1 as u32);
+ if let Some(udev) = self.udev.as_mut() {
+ udev.heads.push(head);
+ udev.disabled_connectors.remove(name);
+ }
+ self.outputs.push(entry);
+ self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(monitor_id, name.to_string(), geometry)));
+ // It's live again - `monitors()` reports it directly now,
+ // so it has no business also showing up in the separate
+ // disabled-outputs listing.
+ self.wm.borrow_mut().clear_disabled_monitor(name);
+ }
+ Err(e) => log::warn!("udev: failed to re-enable {name}: {e}"),
+ }
self.relayout_outputs();
+ // Read back after `relayout_outputs` has assigned this head its
+ // real position, not the `(0, 0)` placeholder it was brought up
+ // at above.
+ if let Some(location) = self.udev.as_ref().and_then(|u| u.heads.iter().find(|h| h.output.name() == name)).map(|h| h.location) {
+ crate::monitor_layout::save_output(name, crate::monitor_layout::PersistedOutput { x: location.x, y: location.y, enabled: true });
+ }
}
/// Repositions every head left-to-right and republishes the new
/// positions to the output globals, the `Space`, and the layer maps.
fn relayout_outputs(&mut self) {
let Some(udev) = self.udev.as_mut() else { return };
- let mut x = 0;
+ // Two separate accumulators, not one - `x_physical` is this
+ // compositor's own internal placement convention (`head.location`,
+ // `Space`, everything else), `x_logical` is what actually goes out
+ // over the wire via `change_current_state`, which the Wayland
+ // protocol always specifies in logical points. At `scale == 1.0`
+ // for every output these are numerically identical, which is why
+ // this was invisible until a non-1.0 scale existed: passing the
+ // *physical* offset straight into `change_current_state` here
+ // (this used to do exactly that, unconditionally) put a second
+ // output's *logical* position short of where the first output's
+ // own *logical* width actually ends whenever a scale below 1.0 was
+ // involved - e.g. a first output that's 1920 physical but 2276
+ // logical (0.843 scale) left the second output advertised at
+ // logical x=1920, deep inside the first one's own logical extent,
+ // not past it. Reported live (measured from inside GTK, not
+ // inferred) as the two outputs' logical rectangles overlapping by
+ // a few hundred pixels - ambiguous "which monitor is this point
+ // on" answers, and hit-testing/screenshots landing on the wrong
+ // output entirely in the overlap band.
+ let mut x_physical = 0;
+ let mut x_logical = 0;
let mut placed: Vec<(Output, Point<i32, Logical>)> = Vec::new();
for head in &mut udev.heads {
- head.location = (x, 0).into();
- head.output.change_current_state(None, None, None, Some((x, 0).into()));
+ let scale = head.output.current_scale().fractional_scale();
+ head.location = (x_physical, 0).into();
+ head.output.change_current_state(None, None, None, Some((x_logical, 0).into()));
placed.push((head.output.clone(), head.location));
- x += head.size.0;
+ x_physical += head.size.0;
+ x_logical += (head.size.0 as f64 / scale).round() as i32;
}
for (output, location) in placed {
if let Some(entry) = self.outputs.iter_mut().find(|e| e.output == output) {
diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs
index 88c0d8f..cbcee64 100644
--- a/crates/wayland/src/udev/platform.rs
+++ b/crates/wayland/src/udev/platform.rs
@@ -10,6 +10,12 @@ pub struct UdevPlatform {
clients: Vec<Client>,
pending: Rc<RefCell<Vec<CoreEvent>>>,
ipc: Option<srdwm_platform::IpcServer>,
+ /// Last time `ipc.poll()` actually ran - see its call site in
+ /// `poll_events` for why this exists at all.
+ last_ipc_poll: Instant,
+ /// Last time the unconditional end-of-cycle `render_udev_frame()` call
+ /// actually ran - see its own call site for why.
+ last_render: Instant,
}
impl UdevPlatform {
@@ -52,16 +58,24 @@ impl UdevPlatform {
let mut heads: Vec<UdevHead> = Vec::new();
let mut output_entries: Vec<crate::state::OutputEntry> = Vec::new();
let mut used_crtcs: Vec<crtc::Handle> = Vec::new();
+ // Two accumulators - see `bring_up_head`'s own doc comment on its
+ // `logical_x` parameter for why a second head's logical position
+ // can't just be derived from the physical offset and its own
+ // scale alone once an earlier head has a *different* scale.
let mut x_offset = 0;
+ let mut logical_x = 0;
for probe in &connected {
let Some(crtc) = pick_crtc(&card, probe, &used_crtcs) else {
log::warn!("udev: no free CRTC left for connector {}; not driving it", probe.name);
continue;
};
- let (head, entry) = bring_up_head(&card, &display_handle, probe, crtc, x_offset)?;
- log::info!("udev: head {}: {} {}x{} at x={x_offset}", heads.len(), probe.name, head.size.0, head.size.1);
+ let scale = wm.borrow().monitor_scale(&probe.name);
+ let (head, entry) = bring_up_head(&card, &display_handle, probe, crtc, x_offset, logical_x, scale)?;
+ log::info!("udev: head {}: {} {}x{} at x={x_offset} (logical x={logical_x})", heads.len(), probe.name, head.size.0, head.size.1);
used_crtcs.push(crtc);
+ let resolved_scale = head.output.current_scale().fractional_scale();
x_offset += head.size.0;
+ logical_x += (head.size.0 as f64 / resolved_scale).round() as i32;
heads.push(head);
output_entries.push(entry);
}
@@ -114,9 +128,11 @@ impl UdevPlatform {
active: true,
pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(),
session: session.clone(),
+ disabled_connectors: std::collections::HashSet::new(),
+ last_rendered_workspace: None,
};
- let state = CompState {
+ let mut state = CompState {
compositor_state,
xdg_shell_state,
_xdg_decoration_state: xdg_decoration_state,
@@ -143,6 +159,7 @@ impl UdevPlatform {
_screencopy_state: crate::screencopy::ScreencopyState::new::<CompState>(&display_handle),
screencopy_pending: Vec::new(),
_appmenu_state: crate::appmenu::AppmenuManagerState::new::<CompState>(&display_handle),
+ _virtual_keyboard_state: smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState::new::<CompState, _>(&display_handle, |_client| true),
_foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&display_handle),
foreign_toplevel_managers: Vec::new(),
foreign_toplevel_handles: HashMap::new(),
@@ -172,6 +189,7 @@ impl UdevPlatform {
last_broadcast_workspace: None,
lock: Default::default(),
cursor_status: smithay::input::pointer::CursorImageStatus::default_named(),
+ decoration_cursor_active: false,
cursor_buffers: crate::cursor::make_buffers(),
last_titlebar_click: None,
gesture_swipe: None,
@@ -189,12 +207,15 @@ impl UdevPlatform {
border_top_decorations: HashMap::new(),
border_bottom_decorations: HashMap::new(),
decoration_signatures: HashMap::new(),
+ hovered_titlebar_button: None,
shadow_buffers: HashMap::new(),
rounded_corners_program: None,
content_epoch: HashMap::new(),
rounded_content_buffers: HashMap::new(),
border_side_buffers: HashMap::new(),
+ color_filter_buffers: HashMap::new(),
last_synced_size: HashMap::new(),
+ pending_size_configure: HashMap::new(),
pending: pending.clone(),
bound_keys: Rc::new(bound_keys.iter().cloned().collect::<HashSet<_>>()),
repeat_keys: Rc::new(repeat_keys.iter().cloned().collect::<HashSet<_>>()),
@@ -209,6 +230,15 @@ impl UdevPlatform {
appmenu_registrar: None,
};
+ // Before the Wayland socket even binds, deliberately - see
+ // `restore_monitor_layout`'s and `monitor_layout`'s own doc
+ // comments for why this compositor restores its own remembered
+ // layout itself rather than leaving it to whichever panel happens
+ // to be running: no client can possibly connect and see the
+ // default, un-restored arrangement, not even for one frame, since
+ // the socket a client would need to connect to doesn't exist yet.
+ state.restore_monitor_layout();
+
let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?;
if let Some(name) = listener.socket_name() {
std::env::set_var("WAYLAND_DISPLAY", name);
@@ -248,7 +278,7 @@ impl UdevPlatform {
log::warn!("XWayland unavailable ({e}); X11-only clients will not run");
}
- Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc })
+ Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc, last_ipc_poll: Instant::now(), last_render: Instant::now() })
}
fn accept_clients(&mut self) -> PlatformResult<()> {
@@ -268,13 +298,97 @@ impl Platform for UdevPlatform {
fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> {
self.accept_clients()?;
+ let dispatch_start = Instant::now();
self.event_loop.dispatch(Some(Duration::from_millis(16)), &mut self.state).map_err(err)?;
+ // `dispatch`'s `Duration::from_millis(16)` argument is a *maximum*
+ // wait, not a guarantee - calloop returns the moment any
+ // registered source looks ready, however long or short that takes.
+ // A source stuck permanently "ready" (an fd calloop never removes
+ // even though every read on it comes back EOF/HUP - confirmed live
+ // via `strace`, traced to the libseat session notifier's internal
+ // ping channel, and reproducible on a bare tty1 login within the
+ // first second of every single srdwm start, independent of which
+ // libseat backend - seatd or the logind fallback - is active)
+ // makes `dispatch` return in microseconds forever, turning this
+ // loop into an unthrottled spin that burns 70-90% of a core doing
+ // nothing: `accept_clients`/`tick_repeat`/`dispatch_clients` all
+ // still run their own (cheap) work on every single one of those
+ // spurious wakeups, thousands of times a second, instead of the
+ // ~60 times a second the 16ms figure was meant to cap it at.
+ //
+ // This doesn't fix *why* that source never goes away - that's
+ // upstream, in calloop/libseat's own channel-notification internals
+ // - but it puts a floor under the symptom regardless of which
+ // source eventually turns out to cause it.
+ //
+ // Sleeping the full remainder of a 16ms cycle on *every* fast
+ // return (an earlier version of this did exactly that) blocks this
+ // thread against everything, not just the next spurious wakeup --
+ // a genuine DRM page-flip completion or a client committing its
+ // next video frame that becomes ready *during* the sleep sits
+ // unprocessed until the sleep ends, instead of being picked up
+ // immediately. Reported live as choppy/laggy video playback: up to
+ // 16ms of pure, avoidable latency added to every frame's worth of
+ // real work that happened to land in that window.
+ //
+ // A per-iteration streak counter was tried first, throttling only
+ // once several fast returns in a row looked like true idle
+ // spinning rather than one-off real work - but `dispatch`'s
+ // return time can't actually distinguish the two here: the dead
+ // pipe is *always* ready, so every call returns in microseconds
+ // whether or not it also picked up something real, and a streak
+ // built on that timing never resets during genuine activity
+ // either. Telling real work apart from the spurious wakeup would
+ // need a signal from *inside* dispatch (e.g. the render path
+ // flagging "a frame actually went out this tick"), which is real
+ // plumbing, not a one-line fix.
+ //
+ // Short of that: cap the sleep itself far below 16ms instead of
+ // trying to skip it selectively. `MIN_CYCLE` (~3ms) still turns
+ // the true spin (unbounded, thousands of empty iterations/sec)
+ // into a bounded few hundred/sec - a real, if smaller, win over
+ // no floor at all - while capping how long any genuinely-ready
+ // event can ever sit blocked to something well under one frame at
+ // 60Hz, rather than up to a full frame's worth of latency.
+ const MIN_CYCLE: Duration = Duration::from_millis(3);
+ let elapsed = dispatch_start.elapsed();
+ if elapsed < MIN_CYCLE {
+ std::thread::sleep(MIN_CYCLE - elapsed);
+ }
// Held bindings that repeat - see `CompState::tick_repeat`.
self.state.tick_repeat();
self.display.dispatch_clients(&mut self.state).map_err(err)?;
self.display.flush_clients().map_err(err)?;
self.state.apply_registrar_events();
- if let Some(ipc) = self.ipc.as_mut() {
+ self.state.poll_global_menu_properties();
+ // Throttled to ~60Hz, not run on every single `poll_events` cycle --
+ // `IpcServer::poll` unconditionally rebuilds and diffs a full
+ // `client_snapshot`/`workspace_snapshot` on every call (cloning each
+ // window's title, app_id, global-menu data, ...) even when nothing
+ // has changed and nobody is subscribed, purely so a real change is
+ // never missed. Cheap at a sane call rate; not cheap at the rate
+ // this loop actually runs at - see `MIN_CYCLE`'s own doc comment
+ // just above: the dead libseat pipe that makes `dispatch` return in
+ // microseconds forever means this whole function's "rest of the
+ // cycle" work already runs at whatever `dispatch` gets bounced to
+ // (a few hundred times a second, floor-capped by `MIN_CYCLE`, not
+ // the ~60 times a second one `Duration::from_millis(16)` above was
+ // meant to imply), and that snapshot/diff cost was riding along at
+ // that same needlessly high rate - measured live as a continuous,
+ // unwavering ~20% of a core even at complete idle, unaffected by
+ // toggling shadows/rounded_corners/animations (all purely per-
+ // render-frame costs, not per-cycle ones, so none of them could
+ // have explained a cost that never budged with the screen doing
+ // nothing). A real `srd dispatch`/`srd set` command still lands
+ // within one throttled window (well under a human's own reaction
+ // time), not delayed by anything close to what would read as
+ // input lag.
+ const IPC_POLL_INTERVAL: Duration = Duration::from_millis(16);
+ let ipc_due = self.last_ipc_poll.elapsed() >= IPC_POLL_INTERVAL;
+ if ipc_due {
+ self.last_ipc_poll = Instant::now();
+ }
+ if let Some(ipc) = self.ipc.as_mut().filter(|_| ipc_due) {
if ipc.poll(&self.state.wm) {
self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged);
// `ipc.rs`'s `handle_request` (`"focus"`, `"toggle
@@ -295,9 +409,16 @@ impl Platform for UdevPlatform {
// unconditionally on any IPC mutation, not just ones that
// are definitely focus changes - raising an already-topmost
// element is a no-op reinsertion.
+ //
+ // `raise_in_space`, not the full `focus_window` - that one
+ // also re-runs `WindowManager::focus_window`'s workspace-
+ // follow side effect on the already-focused window, which
+ // silently reverted any `activate_workspace` IPC dispatch
+ // within this same cycle (see `raise_in_space`'s own doc
+ // comment for the full story).
let focused = self.state.wm.borrow().focused_id();
if let Some(id) = focused {
- crate::input::focus_window(&mut self.state, id);
+ crate::input::raise_in_space(&mut self.state, id);
}
}
}
@@ -344,6 +465,10 @@ impl Platform for UdevPlatform {
log::warn!("udev: set_output_position: no head at index {id}");
continue;
};
+ // `(x, y)` is whatever `srd dispatch set output position`
+ // sent, unconverted - that command's own contract is to
+ // match `srd monitors`' `full_x`/`full_y` (physical),
+ // which is exactly what `apply_output_position` wants.
crate::output_management::apply_output_position(&mut self.state, &output, (x, y).into());
any_applied = true;
}
@@ -361,57 +486,144 @@ impl Platform for UdevPlatform {
self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0))));
}
}
- self.state.render_udev_frame();
+ // Applies any `srd set_output_enabled` IPC requests queued since
+ // the last poll - `disable_connector_by_name`/`enable_connector_
+ // by_name` already push their own `MonitorRemoved`/`MonitorAdded`
+ // event, so nothing further is needed here beyond calling them.
+ let enable_requests = self.state.wm.borrow_mut().drain_output_enable_requests();
+ for (name, enabled) in enable_requests {
+ if enabled {
+ self.state.enable_connector_by_name(&name);
+ } else {
+ self.state.disable_connector_by_name(&name);
+ }
+ }
+ // Throttled the same way and for the same underlying reason as the
+ // `ipc.poll()` call above - this is the *other*, larger half of
+ // this cycle's needless work at the dead-pipe-driven spin rate.
+ // `render_udev_frame` isn't only called from here: a real DRM
+ // page-flip completion (`session.rs`), a VT-switch resume, and an
+ // output hotplug each call it directly, immediately, completely
+ // unthrottled by this - those are genuine, comparatively rare
+ // events that should redraw the instant they happen. This one
+ // specific call site is different: it's the unconditional catch-
+ // all that used to run at the end of *every* cycle regardless of
+ // whether `dispatch` actually picked up anything real, which at
+ // this loop's dead-pipe-driven rate meant re-walking every visible
+ // window, rebuilding the whole `custom_elements` list, and running
+ // Pixman's own damage tracking against it a few hundred times a
+ // second, forever - `has_damage` already meant an idle desktop's
+ // *page flip* was skipped, but computing "no, still nothing to
+ // flip" this often is itself most of the cost this whole function
+ // was found burning at idle. `RENDER_INTERVAL` (~8ms, ~120Hz) is
+ // comfortably above any real display's refresh rate - a head can
+ // never actually present faster than its own vblank allows
+ // regardless (`flip_pending` already gates that) - so this cannot
+ // cap real, on-screen frame rate on any hardware this backend
+ // targets; it only stops the redundant "check again" calls in
+ // between.
+ const RENDER_INTERVAL: Duration = Duration::from_millis(8);
+ if self.last_render.elapsed() >= RENDER_INTERVAL {
+ self.last_render = Instant::now();
+ self.state.render_udev_frame();
+ }
Ok(self.pending.borrow_mut().drain(..).collect())
}
- /// One `srdwm_core::Monitor` per head, positioned in the global space.
- /// This is what makes core's layout engine multi-monitor-aware in
- /// practice: `arrange_workspace` groups windows by `monitor` and lays
- /// each group out inside that monitor's rectangle.
+ /// One `srdwm_core::Monitor` per head, positioned in the global space
+ /// - or several, when `srd.monitor.split` has requested that head be
+ /// divided into logical sub-monitors ("monitors inside monitors"; see
+ /// `srdwm_core::monitor::MonitorSplit`'s own doc comment). This is
+ /// what makes core's layout engine multi-monitor-aware in practice:
+ /// `arrange_workspace` groups windows by `monitor` and lays each group
+ /// out inside that monitor's rectangle - a split just means more,
+ /// smaller rectangles feeding the same grouping, no other core-side
+ /// change needed.
fn monitors(&mut self) -> PlatformResult<Vec<srdwm_core::Monitor>> {
let Some(udev) = self.state.udev.as_ref() else { return Ok(Vec::new()) };
- Ok(udev
- .heads
- .iter()
- .enumerate()
- .map(|(i, head)| {
- // Shrunk by whatever a layer-shell surface (bar, dock) has
- // reserved via `set_exclusive_zone` - reporting the full
- // head size here otherwise means core's placement/tiling
- // treats that strip as ordinary free space, so a new
- // window's titlebar lands right where the bar renders on
- // top of it, unreachable to drag. `non_exclusive_zone()` is
- // output-local, so it's translated into this head's
- // position in the shared global space the same way
- // `head.location` already is.
- let zone = layer_map_for_output(&head.output).non_exclusive_zone();
- let rect = srdwm_core::Rect::new(
- head.location.x + zone.loc.x,
- head.location.y + zone.loc.y,
- zone.size.w as u32,
- zone.size.h as u32,
- );
- let mut m = srdwm_core::Monitor::new(i as u32, head.output.name(), rect);
- // `Monitor::new` defaults `full_geometry` to whatever
- // `geometry` was constructed with - correct for a monitor
- // with no layer-shell client at all, wrong the moment one
- // exists, since `rect` above is already zone-shrunk. Without
- // this, `full_geometry` was silently identical to `geometry`
- // for every real monitor this backend ever reported, which
- // made `toggle_fullscreen`'s whole "ignore the reserved
- // zone" design a no-op in practice: fullscreen still
- // stopped at the bar/dock exactly like maximize does.
- // Reported live as "fullscreen isn't actually going
- // fullscreen" - confirmed by triggering it and reading
- // the resulting geometry back over IPC, not just from
- // reading this code.
- m.full_geometry = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32);
- m.maximize_geometry = crate::input::maximize_geometry_for(&head.output, m.full_geometry);
- m.primary = i == 0;
- m
- })
- .collect())
+ let wm = self.state.wm.clone();
+ let wm = wm.borrow();
+ let mut out = Vec::new();
+ let mut next_id: u32 = 0;
+ for head in udev.heads.iter() {
+ // Shrunk by whatever a layer-shell surface (bar, dock) has
+ // reserved via `set_exclusive_zone` - reporting the full
+ // head size here otherwise means core's placement/tiling
+ // treats that strip as ordinary free space, so a new
+ // window's titlebar lands right where the bar renders on
+ // top of it, unreachable to drag. `non_exclusive_zone()` is
+ // output-local, so it's translated into this head's
+ // position in the shared global space the same way
+ // `head.location` already is.
+ //
+ // `non_exclusive_zone()` is in *logical* (scale-divided)
+ // units - a bar reports its own reserved strip the way every
+ // layer-shell client does, in logical points - while `head.
+ // location`/`head.size` are raw physical pixels straight from
+ // the DRM mode, never touched by `srd.monitor.scale`. Left
+ // unconverted, `usable` silently mixed the two units on any
+ // output with a scale other than exactly `1.0`: at scale
+ // `0.712`, a 1920-physical-pixel-wide head's own `zone.size.w`
+ // came back as ~2697 (logical), reported as this monitor's
+ // *usable* width - larger than its own *full* width, and
+ // large enough to overlap whichever real monitor sat next to
+ // it in the shared global space. Reported live as "Firefox
+ // maximized on one monitor also shows partially on the
+ // other" and general visual glitching on the scaled output --
+ // both are this: placement math trusting an oversized rect
+ // that reached into a neighboring monitor's real screen.
+ // Scaling `zone` back into physical pixels here keeps `usable`
+ // in the same unit as `full`/`maximize`/`head.location`
+ // everywhere else in this compositor.
+ let zone = layer_map_for_output(&head.output).non_exclusive_zone();
+ let scale = head.output.current_scale().fractional_scale();
+ let zone_physical = |v: i32| (v as f64 * scale).round() as i32;
+ let usable = srdwm_core::Rect::new(
+ head.location.x + zone_physical(zone.loc.x),
+ head.location.y + zone_physical(zone.loc.y),
+ zone_physical(zone.size.w).max(0) as u32,
+ zone_physical(zone.size.h).max(0) as u32,
+ );
+ // The head's true full rect, ignoring any exclusive zone --
+ // deliberately *not* defaulted from `usable` the way `Monitor::
+ // new` alone would (see the fullscreen note below).
+ let full = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32);
+ let maximize = crate::input::maximize_geometry_for(&head.output, full);
+ let name = head.output.name();
+ let split = wm.monitor_split(&name);
+ let parts = split.map(|s| s.parts).unwrap_or(1).max(1);
+ let rows = split.map(|s| s.rows).unwrap_or(false);
+ for part in 0..parts {
+ let sub_name = if parts <= 1 { name.clone() } else { format!("{name}-{}", part + 1) };
+ let mut m = srdwm_core::Monitor::new(next_id, sub_name, srdwm_core::monitor::split_rect(usable, part, parts, rows));
+ // `Monitor::new` defaults `full_geometry`/`maximize_
+ // geometry` to whatever `geometry` was constructed with --
+ // correct for a monitor with no layer-shell client and no
+ // split at all, wrong the moment either exists, since the
+ // rect above may already be zone-shrunk and/or a sub-
+ // region. Without this, `full_geometry` was silently
+ // identical to `geometry` for every real monitor this
+ // backend ever reported, which made `toggle_fullscreen`'s
+ // whole "ignore the reserved zone" design a no-op in
+ // practice: fullscreen still stopped at the bar/dock
+ // exactly like maximize does. Reported live as "fullscreen
+ // isn't actually going fullscreen" - confirmed by
+ // triggering it and reading the resulting geometry back
+ // over IPC, not just from reading this code. Each split
+ // part gets its *own* full/maximize rect too - without
+ // this, fullscreening a window in either half of a split
+ // head would cover the *entire* physical panel, silently
+ // erasing the split it was placed to respect.
+ m.full_geometry = srdwm_core::monitor::split_rect(full, part, parts, rows);
+ m.maximize_geometry = srdwm_core::monitor::split_rect(maximize, part, parts, rows);
+ m.primary = next_id == 0;
+ m.split = parts > 1;
+ m.scale = scale;
+ out.push(m);
+ next_id += 1;
+ }
+ }
+ Ok(out)
}
fn apply_geometry(&mut self, window: srdwm_core::WindowId, _geometry: srdwm_core::Rect) -> PlatformResult<()> {
diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs
index 0647e26..3a62292 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -8,6 +8,7 @@ impl CompState {
/// instead of the slowest one gating the rest.
pub(crate) fn render_udev_frame(&mut self) {
self.tick_animations();
+ self.tick_hover_glyph_animation();
self.tick_dirty_broadcasts();
let locked = self.lock.locked;
let elapsed = self.start_time.elapsed();
@@ -47,9 +48,22 @@ impl CompState {
// looked up fresh per head (head-local `origin` translation).
let ids: Vec<srdwm_core::WindowId> = if locked { Vec::new() } else { self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect() };
let focused = self.wm.borrow().focused_id();
- // Default `false` here, unlike winit's `unwrap_or(true)` - see
- // `rounded_corners_pixman`'s module doc comment for the CPU cost
- // that makes this backend opt-in rather than on by default.
+ // Stays default `false` here, unlike winit's `unwrap_or(true)` --
+ // see `rounded_corners_pixman`'s module doc comment for the real
+ // CPU cost this backend's masking technique has: a full row-by-row
+ // buffer copy on *every commit* of a constantly-repainting client,
+ // and that doc comment names video specifically as the case that
+ // pays it in full, every frame, for as long as the feature is on.
+ // Flipping this default was tried and reverted in the same pass
+ // that fixed this backend's render-loop latency (see `poll_events`'
+ // own history) - turning it on here would have directly undone
+ // that fix for exactly the content (video) it mattered most for.
+ // The actual "not all windows curved" complaint this was meant to
+ // address (an undecorated/CSD window like Firefox, with no
+ // compositor-drawn titlebar and only a thin border strip to look
+ // rounded at all) is better addressed by giving that border strip
+ // enough rows to show a real curve - see `ThemeConfig::
+ // default_border_width`'s own doc comment.
let rounded_corners_enabled = self.wm.borrow().rounded_corners_enabled.unwrap_or(false);
let popup_targets = if locked { Vec::new() } else { crate::elements::popup_targets(self) };
@@ -58,7 +72,7 @@ impl CompState {
// `captures` taken above nowhere to go this pass - put them back
// rather than silently dropping a client's pending screenshot
// because a VT switch happened to be in progress at that instant.
- let Some(udev) = self.udev.as_ref() else {
+ let Some(udev) = self.udev.as_mut() else {
self.screencopy_pending.extend(captures);
return;
};
@@ -66,6 +80,62 @@ impl CompState {
self.screencopy_pending.extend(captures);
return;
}
+ // A workspace switch changes *which windows* `custom_elements`
+ // includes as drastically as a VT switch changes what's been
+ // scanned out in the meantime (see `register_session_notifier`'s
+ // own `head.ages = [0, 0]` for that case) - reported live as
+ // visible corruption (stale, wrong-coloured blocks, worst on a
+ // window that was actively repainting - a scrolling terminal --
+ // right as the switch happened) confined to exactly the frame or
+ // two around a switch, then never self-correcting, consistent with
+ // one transient frame's content getting baked into a buffer slot
+ // and never fully overwritten again since later frames only patch
+ // whatever's *actually* still changing. `render_output`'s own
+ // per-element diffing (`elements_gone`/moved-element damage, plus
+ // each element's own `damage_since`) should in principle already
+ // produce correct total damage for a completely different element
+ // list - this is a defensive belt-and-braces reset, not a
+ // fallback for a specific proven bug in that diffing, matched to
+ // the one other place in this codebase that already resets `ages`
+ // for the same underlying reason ("what's in this buffer might not
+ // be what the tracker's own history thinks it is").
+ let current_workspace = self.wm.borrow().current_workspace();
+ if udev.last_rendered_workspace != Some(current_workspace) {
+ udev.last_rendered_workspace = Some(current_workspace);
+ for head in &mut udev.heads {
+ head.ages = [0, 0];
+ }
+ }
+ // A head whose page-flip event never arrives (kernel-dropped, or a
+ // DRM event this driver never sends for reasons this backend has no
+ // visibility into) would otherwise sit in `flip_pending` forever:
+ // `session.rs`'s DRM-fd handler is the only other place that clears
+ // it, and it can only do that in response to an event that actually
+ // shows up. A head stuck this way is excluded from `ready` below on
+ // every single tick from then on - silently frozen on whatever it
+ // last displayed, with no error logged anywhere (the flip that set
+ // `flip_pending` had already succeeded when it was issued), which
+ // is exactly what a real second monitor did live: it rendered
+ // nothing but its own initial clear colour for the rest of the
+ // session, from moments after being connected. `FLIP_TIMEOUT` is
+ // far above any real vblank interval (even 30Hz is ~33ms) but short
+ // enough that a genuine loss is invisible in practice; forcing
+ // `flip_pending` back to `false` here just lets the normal path
+ // below retry - if a flip is still genuinely in flight, the
+ // kernel's own EBUSY on the next `page_flip` call surfaces as the
+ // existing "udev: page flip failed" log line instead of a silent
+ // freeze.
+ const FLIP_TIMEOUT: Duration = Duration::from_millis(200);
+ for head in udev.heads.iter_mut() {
+ if head.flip_pending && head.flip_pending_since.elapsed() > FLIP_TIMEOUT {
+ log::warn!(
+ "udev: no page-flip event for output {} after {:?}; forcing recovery",
+ head.output.name(),
+ head.flip_pending_since.elapsed()
+ );
+ head.flip_pending = false;
+ }
+ }
let now = Instant::now();
let ready: Vec<(usize, Output)> = udev
.heads
@@ -84,7 +154,7 @@ impl CompState {
// frame-callback loop below (after `udev` is no longer borrowed)
// can notify only the windows that damage actually overlapped --
// see `windows_touched_by_damage`'s doc comment in elements.rs.
- let mut presented: Vec<(Output, Vec<Rectangle<i32, Physical>>)> = Vec::new();
+ let mut presented: Vec<(Output, Point<i32, Logical>, Vec<Rectangle<i32, Physical>>)> = Vec::new();
for (index, output) in ready {
let lock_surface = self.lock_surface_for(&output).cloned();
// Extracted before the `self.udev` borrow below starts - see
@@ -117,6 +187,16 @@ impl CompState {
origin,
hsize,
));
+ // Night light/reading mode - a translucent full-output
+ // overlay, pushed right after the cursor so it colours
+ // everything else (windows, bars, menus) but never the
+ // pointer itself. See `color_filter::render_element` for
+ // why an overlay rather than a true per-pixel shader.
+ let color_filter = self.wm.borrow().color_filter;
+ let buf = self.color_filter_buffers.entry(output.name()).or_insert_with(SolidColorBuffer::default);
+ if let Some(elem) = crate::color_filter::render_element(buf, color_filter, hsize) {
+ custom_elements.push(crate::elements::OverlayElement::Solid(elem));
+ }
// The right-click titlebar menu, if open - pushed right
// after the cursor so it's still topmost over every window
// but never hides the pointer itself (you need to see what
@@ -165,7 +245,6 @@ impl CompState {
custom_elements.extend(crate::elements::output_layer_elements(
&mut udev.renderer,
&output,
- (origin.x, origin.y),
|layer| matches!(layer, Layer::Top | Layer::Overlay),
));
}
@@ -204,39 +283,164 @@ impl CompState {
// windows) has to agree with what `sync_geometry` mapped
// the content to, or they drift apart again.
let geom = self.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry);
- // Drawn first among this window's own decoration, and
- // positioned from the same animated `geom` as everything
- // else here - not `w.geometry` - for the identical
- // reason: a shadow that stayed at the pre-tween rect
- // while the window slid past it would look exactly as
- // detached as the border did before that fix.
- //
- // Fragment-clipped against `occluders` now, same as the
- // titlebar/border below - this used to skip that on the
- // reasoning that `SHADOW_MAX_ALPHA`'s low opacity would
- // read as a soft edge, not the hard-line bleed-through
- // that made the titlebar/border need it. True along a
- // shadow's straight edges, false at its corners:
- // `shadow_bitmap` falls off by Chebyshev (square-ring)
- // distance, not radial, so each corner is a hard-edged
- // square block at up to ~35% opacity, not a soft
- // vignette - reported live as a small dark rectangular
- // patch sitting on top of whatever window a floating/
- // cascaded window's own corner happened to overlap,
- // most visible exactly where two windows' corners
- // nearly meet, which this compositor's default cascade
- // placement does constantly.
- if let Some(shadow) = self.shadow_buffers.get(&id) {
- let rect = decoration::shadow_rect(geom);
- for fragment in crate::elements::visible_border_fragments(rect, &occluders) {
- let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64);
- let src = Rectangle::new(
- Point::from(((fragment.x - rect.x) as f64, (fragment.y - rect.y) as f64)),
- Size::from((fragment.width as f64, fragment.height as f64)),
- );
- match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, shadow, None, Some(src), None, Kind::Unspecified) {
- Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
- Err(e) => log::warn!("udev: failed to import shadow buffer: {e}"),
+ // `geom` above is this compositor's own request/target;
+ // `frame` corrects its far edge to match what the
+ // client's surface really committed (a terminal's
+ // cell-quantized size, most commonly) - see
+ // `effective_frame`'s own doc comment. Everything below
+ // that has to visually hug the real edge (titlebar/
+ // border placement, the shadow, the occlusion test
+ // against windows behind this one) reads `frame`; only
+ // the actual content position still reads `geom`/`band`
+ // directly, since that's already correctly anchored via
+ // `content_offset` below regardless of this correction.
+ let frame = crate::state::CompState::effective_frame_of(&self.wm, &self.id_to_window, id, geom);
+ // Computed here, ahead of the border strips below,
+ // purely so they can know it - the actual content
+ // element that reads this same masked buffer is still
+ // pushed later, in its own usual place in the loop, and
+ // gets a cheap cache hit from `rounded_content_buffer`'s
+ // own `epoch`/`radius_bits` check rather than doing the
+ // masking work twice. `w.decorated` alone used to gate
+ // whether the border strips' own "extra" rows (see
+ // `decoration::border_top_visible_rows`'s doc comment)
+ // were safe to draw past their nominal `border_width` --
+ // correct for a decorated window (a titlebar band
+ // absorbs them) but not for an undecorated one, which
+ // relies on content-masking instead, and several real
+ // clients (Firefox, confirmed live) never actually get
+ // masked at all (`masked_content_buffer`'s own
+ // subsurface early-out). Cropping unconditionally
+ // whenever undecorated (the fix's first version) closed
+ // the wedge bug but cost every undecorated window its
+ // own visible corner curve even when masking *did*
+ // succeed, which is unnecessary - this makes that
+ // decision follow the real per-window, per-frame
+ // outcome instead of just the static `decorated` flag.
+ // `masked.is_some()` alone used to be the whole check,
+ // back when masking meant identifying and reading one
+ // specific client subsurface directly - wrong the
+ // moment the resolved child excluded more of the root
+ // than the client's own declared shadow margin (a GTK4
+ // client legitimately reserves an invisible margin for
+ // its own drop shadow, but Firefox's tab strip/title row
+ // is painted on the *root* surface outside its content
+ // child, and once that surface-picking heuristic got
+ // permissive enough to mask Firefox too, it silently
+ // deleted Firefox's real tab strip - reported live as
+ // "Firefox's titlebar turned invisible", confirmed by
+ // toggling `general.rounded_corners` off, which brought
+ // it straight back). `rounded_corners_pixman::masked_
+ // content_buffer` no longer has that failure mode at
+ // all: it renders the window's *whole* surface tree into
+ // its own off-screen buffer and masks the composited
+ // result, the same thing a GPU shader-based compositor
+ // does by construction - so `.is_some()` is genuinely
+ // the whole answer again. `loc`/`content_size` mirror
+ // the real content push's own `content_offset`/`band`
+ // correction below (`pos`'s own doc comment) - both
+ // call sites have to agree on the origin/size a mask was
+ // built at, or `rounded_content_buffer`'s cache would
+ // never consider one stale after a resize.
+ let content_will_be_masked = if rounded_corners_enabled && self.wm.borrow().resizing_window() != Some(id) {
+ let content_offset = self.id_to_window.get(&id).map(|dw| dw.geometry().loc).unwrap_or_default();
+ let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 };
+ let content_size = (frame.width as i32, (frame.height as i32 - band).max(0));
+ let loc = (-content_offset.x, -content_offset.y);
+ self.id_to_window
+ .get(&id)
+ .and_then(crate::elements::window_wl_surface)
+ .map(|surface| {
+ let epoch = self.content_epoch.get(&id).copied().unwrap_or(0);
+ let corners = if w.decorated { crate::rounded_corners::RoundedCorners::BOTTOM_ONLY } else { crate::rounded_corners::RoundedCorners::ALL };
+ crate::elements::rounded_content_buffer(&mut self.rounded_content_buffers, &mut udev.renderer, epoch, id, &surface, loc, content_size, w.corner_radius as f32, corners).is_some()
+ })
+ .unwrap_or(false)
+ } else {
+ false
+ };
+ let border_curve_is_safe = w.decorated || content_will_be_masked;
+ // Temporary: a peer session precisely measured a real
+ // window's border curving correctly while its content
+ // stayed hard-square (radius 0), despite both this
+ // probe and the real content-render call ~200 lines
+ // below passing identical arguments against the same
+ // cache - logs the three inputs that decide which
+ // branch each one actually takes, so a live repro
+ // says definitively whether `w.decorated` is really
+ // `false` here (the rule's own intent) or the mask
+ // genuinely succeeds-then-somehow-doesn't-render.
+ // Remove once resolved.
+ log::debug!(
+ "udev::render: corner-mask state for {} (id {id:?}): decorated={} content_will_be_masked={content_will_be_masked} border_curve_is_safe={border_curve_is_safe} resizing={}",
+ w.app_id,
+ w.decorated,
+ self.wm.borrow().resizing_window() == Some(id)
+ );
+ // Pushed *before* the titlebar band below, deliberately --
+ // unlike the bottom/side strips further down, this one
+ // isn't confined to `geometry`'s own outside: whenever
+ // `corner_radius > border_width` (the common case: 12 vs
+ // 4 by default), `border_top_visible_rows` deliberately
+ // extends this buffer `corner_radius - border_width` rows
+ // *past* its nominal thickness, straight down into the
+ // titlebar band's own top rows, so the one shared curve
+ // has room to finish (see that function's and `render_
+ // border_top`'s own doc comments). For that overlap to
+ // read as one continuous curve rather than the titlebar's
+ // own, differently-centred corner mask poking a square
+ // notch through it, this element's border-coloured
+ // corner columns have to actually paint over the
+ // titlebar's own attempt at those same pixels - which
+ // only happens if this pushes first. Reported live,
+ // confirmed via a zoomed screenshot: pushed after the
+ // titlebar (the previous order), the titlebar's own
+ // smaller, square-under-the-curve corner rendered on top
+ // instead, since `custom_elements` composites earlier-
+ // pushed entries over later ones - exactly backwards
+ // from what this overlap needs.
+ if w.border_width > 0 {
+ let strips = decoration::border_strips(frame, w.border_width);
+ // Strip 0 (top) rounded on its own two corners - see
+ // `render_border_top`'s own doc comment - so it's a
+ // cached bitmap (rebuilt only in `redraw_decoration_
+ // buffer`, same as the titlebar itself), not
+ // rasterized fresh here every frame. Not fragment-
+ // clipped like the left/right strips further down --
+ // cropping a bitmap's source rect per fragment is
+ // real extra work for a strip that's only `border_
+ // width` pixels tall to begin with, so this only
+ // handles the all-or-nothing case: skip entirely
+ // once *fully* covered, accept a small residual
+ // bleed while only partially covered.
+ if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() {
+ if let Some(buffer) = self.border_top_decorations.get(&id) {
+ // See `decoration::border_top_visible_rows`'s
+ // own doc comment: an undecorated window's
+ // top strip crops away this buffer's
+ // titlebar-band-only "extra" rows, which
+ // otherwise paint a border-coloured wedge
+ // straight onto its real content - reported
+ // live on a real Firefox window, confirmed
+ // via a screenshot to be neither Firefox's
+ // own rendering nor the separate content-
+ // mask feature.
+ let (row0, rows, shift) = decoration::border_top_visible_rows(border_curve_is_safe, w.border_width, w.corner_radius);
+ let pos = ((strips[0].x - origin.x) as f64, (strips[0].y - origin.y + shift as i32) as f64);
+ let src = Some(Rectangle::new(Point::from((0.0, row0 as f64)), Size::from((strips[0].width as f64, rows as f64))));
+ // Temporary: chasing a live report that the
+ // bottom two corners render square while the
+ // top two curve correctly, on the same
+ // window, same frame. Logs this strip's own
+ // computed rows/shift/position so a live
+ // repro can be compared directly against the
+ // matching bottom-strip line below. Remove
+ // once resolved.
+ log::debug!("udev::render: TOP border strip for {} (id {id:?}): row0={row0} rows={rows} shift={shift} pos={pos:?} strip_rect={:?}", w.app_id, strips[0]);
+ match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, src, None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("udev: failed to import top border buffer: {e}"),
+ }
}
}
}
@@ -253,7 +457,7 @@ impl CompState {
// visible fragment can come from the matching
// sub-rect of the source image rather than the
// whole thing.
- let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT);
+ let titlebar_rect = srdwm_core::Rect::new(frame.x, frame.y, frame.width, srdwm_core::TITLEBAR_HEIGHT);
for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) {
let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64);
let src = Rectangle::new(
@@ -266,47 +470,47 @@ impl CompState {
}
}
}
- // Border strips sit entirely outside this window's own
- // `geometry` (see `decoration::border_strips`), so they
- // never overlap its own decoration/content - draw
- // order against those doesn't matter here, only against
- // other windows', which iterating `ids` in stacking
- // order already gets right *for windows also drawn via
- // this same custom_elements loop* - but not against
- // any window's own *content*, which is why `occluders`
- // below is still needed even with that ordering.
+ // The bottom strip sits entirely outside this window's
+ // own `geometry` with no titlebar-style overlap into
+ // content the way the top strip's own "extra" rows do
+ // above, so push order against the titlebar doesn't
+ // matter for it - only against other windows', which
+ // iterating `ids` in stacking order already gets right
+ // *for windows also drawn via this same custom_elements
+ // loop* - but not against any window's own *content*,
+ // which is why `occluders` below is still needed even
+ // with that ordering.
+ //
+ // The left/right side strips are a different story --
+ // see their own push site further down for why they
+ // (unlike the bottom strip) *do* need cropping against
+ // this same top/bottom-strip overlap, a real bug this
+ // comment used to claim didn't exist here at all.
if w.border_width > 0 {
- let color = crate::state::effective_border_color(w.border_color, focused == Some(id));
- let strips = decoration::border_strips(geom, w.border_width);
- // Strips 0/1 (top/bottom) rounded on their own two
- // corners - see `render_border_top`/
- // `render_border_bottom`'s doc comments - so both
- // are cached bitmaps (rebuilt only in
- // `redraw_decoration_buffer`, same as the titlebar
- // itself), not rasterized fresh here every frame.
- // Not fragment-clipped like the left/right strips
- // below - cropping a bitmap's source rect per
- // fragment is real extra work for a strip that's
- // only `border_width` pixels tall to begin with, so
- // this only handles the all-or-nothing case: skip
- // entirely once *fully* covered, accept a small
- // residual bleed while only partially covered.
- if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() {
- if let Some(buffer) = self.border_top_decorations.get(&id) {
- let pos = ((strips[0].x - origin.x) as f64, (strips[0].y - origin.y) as f64);
- match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) {
- Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
- Err(e) => log::warn!("udev: failed to import top border buffer: {e}"),
- }
- }
- }
- // Same all-or-nothing bitmap treatment as the top
- // strip, for its own two corners - see
- // `decoration::render_border_bottom`'s doc comment.
+ let color = crate::state::effective_border_color(w.border_color, focused == Some(id), self.wm.borrow().theme.border_inactive_dim);
+ let strips = decoration::border_strips(frame, w.border_width);
+ // Strip 1 (bottom), the top strip's own mirror --
+ // see `decoration::render_border_bottom`'s doc
+ // comment. Same all-or-nothing bitmap treatment.
if strips[1].width > 0 && strips[1].height > 0 && !strips[1].subtract_all(&occluders).is_empty() {
if let Some(buffer) = self.border_bottom_decorations.get(&id) {
- let pos = ((strips[1].x - origin.x) as f64, (strips[1].y - origin.y) as f64);
- match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) {
+ // See `decoration::border_bottom_visible_
+ // rows`'s own doc comment: relies on
+ // `BOTTOM_ONLY` content-masking having made
+ // this corner of a decorated window's
+ // content transparent already, which several
+ // real undecorated clients (Firefox,
+ // confirmed live) never actually get - same
+ // wedge bug as the top strip, confirmed on
+ // the same window's bottom-left corner via a
+ // real screenshot, not assumed.
+ let (row0, rows, shift) = decoration::border_bottom_visible_rows(border_curve_is_safe, w.border_width, w.corner_radius);
+ let pos = ((strips[1].x - origin.x) as f64, (strips[1].y - origin.y - shift as i32) as f64);
+ let src = Some(Rectangle::new(Point::from((0.0, row0 as f64)), Size::from((strips[1].width as f64, rows as f64))));
+ // Temporary: see the matching TOP border log
+ // above. Remove once resolved.
+ log::debug!("udev::render: BOTTOM border strip for {} (id {id:?}): row0={row0} rows={rows} shift={shift} pos={pos:?} strip_rect={:?}", w.app_id, strips[1]);
+ match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, src, None, Kind::Unspecified) {
Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
Err(e) => log::warn!("udev: failed to import bottom border buffer: {e}"),
}
@@ -322,9 +526,45 @@ impl CompState {
// visible after subtracting `occluders`, since a
// whole unclipped strip is exactly the bug fixed
// here.
+ //
+ // Cropped top and bottom by `extra` - the same
+ // `corner_radius - border_width` gap `border_top_
+ // visible_rows`/`border_bottom_visible_rows` extend
+ // the top/bottom strips *into* whenever the radius
+ // exceeds the border's own nominal thickness (the
+ // common case: 12+ vs 4 at this theme's defaults).
+ // These side strips are plain flat fills with no
+ // curve awareness of their own (see this file's own
+ // stale comment just below, corrected here: "sit
+ // entirely outside... no titlebar-style overlap"
+ // was wrong - they *do* overlap the top/bottom
+ // strip's own extended, curved region), and used to
+ // span the window's full nominal height
+ // unconditionally. Since the top/bottom strip is
+ // pushed *before* these (earlier = topmost, see
+ // this loop's own ordering), its own curve's
+ // transparent cutout should be what shows through
+ // there - but a flat, uncropped side strip sitting
+ // directly underneath filled that same "supposed to
+ // be cut away" region with solid colour instead,
+ // which the curve's transparency does nothing to
+ // hide, since the side strip isn't part of what the
+ // curve is cutting *out of*. Reported live as a
+ // straight vertical line poking out from inside an
+ // otherwise-correctly-curved corner, confirmed via
+ // raw pixel sampling: solid border colour at a
+ // fixed x, starting right at the window's nominal
+ // top edge, running in parallel with the real
+ // curve rather than being replaced by it.
+ let extra = if border_curve_is_safe { w.border_width.max(w.corner_radius).saturating_sub(w.border_width) } else { 0 };
+ let mut side_strips = [strips[2], strips[3]];
+ for s in &mut side_strips {
+ s.y += extra as i32;
+ s.height = s.height.saturating_sub(2 * extra);
+ }
let pool = self.border_side_buffers.entry(id).or_default();
let mut buf_index = 0;
- for strip in &strips[2..] {
+ for strip in &side_strips {
if strip.width == 0 || strip.height == 0 {
continue;
}
@@ -335,6 +575,60 @@ impl CompState {
}
}
}
+ // Shadow, positioned from the same animated `geom` as
+ // everything else here - not `w.geometry` - for the
+ // same reason a stale-position border read as detached
+ // from a mid-tween window before that fix: see `geom`'s
+ // own doc comment above. Pushed *after* the titlebar/
+ // border above, not before - `custom_elements` treats
+ // earlier-pushed as topmost (see `border_side_render_
+ // element`'s doc comment), and a shadow pushed first
+ // rendered on top of this same window's own border
+ // strips, alpha-blending black over them and muting the
+ // configured border colour into a hazy, indistinct
+ // smear instead of a crisp line. Reported live as
+ // "spacing before the border" - confirmed by sampling
+ // pixels straight across a window's edge: no run of the
+ // configured border colour appeared anywhere, just a
+ // gradient straight from content black into the
+ // shadow's own falloff. `shadow_bitmap`'s own doc
+ // comment already assumed "the window's own border/
+ // titlebar/content always draws over it" - true for
+ // content (spatially disjoint from the shadow's
+ // rendered rect either way) but not for the border,
+ // which sits inside the shadow's footprint and needs
+ // the *later* push, not the earlier one, to actually
+ // end up on top of it.
+ //
+ // Fragment-clipped against `occluders` now, same as the
+ // titlebar/border above - this used to skip that on
+ // the reasoning that `SHADOW_MAX_ALPHA`'s low opacity
+ // would read as a soft edge, not the hard-line bleed-
+ // through that made the titlebar/border need it. True
+ // along a shadow's straight edges, false at its
+ // corners: `shadow_bitmap` falls off by Chebyshev
+ // (square-ring) distance, not radial, so each corner is
+ // a hard-edged square block at up to ~35% opacity, not
+ // a soft vignette - reported live as a small dark
+ // rectangular patch sitting on top of whatever window a
+ // floating/cascaded window's own corner happened to
+ // overlap, most visible exactly where two windows'
+ // corners nearly meet, which this compositor's default
+ // cascade placement does constantly.
+ if let Some(shadow) = self.shadow_buffers.get(&id) {
+ let rect = decoration::shadow_rect(frame);
+ for fragment in crate::elements::visible_border_fragments(rect, &occluders) {
+ let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64);
+ let src = Rectangle::new(
+ Point::from(((fragment.x - rect.x) as f64, (fragment.y - rect.y) as f64)),
+ Size::from((fragment.width as f64, fragment.height as f64)),
+ );
+ match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, shadow, None, Some(src), None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("udev: failed to import shadow buffer: {e}"),
+ }
+ }
+ }
// The window's own content, at its own `opacity` --
// this, not decoration, is the entire reason content
// moved into this loop at all (see the doc comment on
@@ -372,7 +666,35 @@ impl CompState {
let content_offset = dwindow.geometry().loc;
let pos = (geom.x - origin.x - content_offset.x, geom.y + band - origin.y - content_offset.y);
let mut rounded_elem = None;
- if rounded_corners_enabled {
+ // Skipped for whichever window is being
+ // interactively resized right now, specifically
+ // (not gated on `is_resizing()` alone, which
+ // would also blank every *other* window's own
+ // masking for the duration): `rounded_content_
+ // buffer`'s own doc comment already flagged this
+ // backend's real CPU cost - a full row-by-row
+ // copy of the surface's *entire* pixel buffer on
+ // every commit, unlike the free-on-GPU winit/
+ // GLES path - and a resize is exactly the case
+ // that pays it hardest: content reflows and
+ // recommits on every single frame of the drag,
+ // not just once. Reported live as "resizing is
+ // very laggy" the first time this session real
+ // hardware actually exercised `general.
+ // rounded_corners` turned on at all (it defaults
+ // off for exactly this reason). The corner mask
+ // is cosmetic and this is the one moment its
+ // absence is least likely to be noticed --
+ // attention is on the edge being dragged, not
+ // the opposite corner's curve - so skipping it
+ // for the resize's duration and letting it
+ // reappear the instant it ends (no cache
+ // invalidation needed either way: `epoch`
+ // already only rebuilds on a real content
+ // change) is a real fix, not a visible
+ // regression.
+ let being_resized = self.wm.borrow().resizing_window() == Some(id);
+ if rounded_corners_enabled && !being_resized {
let epoch = self.content_epoch.get(&id).copied().unwrap_or(0);
// Bottom-only for a decorated window, same
// reasoning as `winit/render.rs`'s identical split:
@@ -380,11 +702,24 @@ impl CompState {
// under the titlebar band's own rounded
// bitmap.
let corners = if w.decorated { crate::rounded_corners::RoundedCorners::BOTTOM_ONLY } else { crate::rounded_corners::RoundedCorners::ALL };
- if let Some(buffer) =
- crate::elements::rounded_content_buffer(&mut self.rounded_content_buffers, epoch, id, &surface, w.corner_radius as f32, corners)
- {
- match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, (pos.0 as f64, pos.1 as f64), buffer, Some(w.opacity), None, None, Kind::Unspecified)
- {
+ // `content_offset`/`band` above already give
+ // this window's own content origin/size; the
+ // mask's own off-screen buffer is rendered
+ // and sized to match exactly, so (unlike the
+ // old per-subsurface-buffer approach) the
+ // result can simply be placed at plain `pos`
+ // below - see `rounded_corners_pixman`'s own
+ // module doc comment for why this no longer
+ // needs a separate offset or a safety check
+ // against `content_offset` at all: the whole
+ // surface tree is what gets masked now, not
+ // one guessed-at subsurface, so there is
+ // nothing left it could silently exclude.
+ let content_size = (frame.width as i32, (frame.height as i32 - band).max(0));
+ let loc = (-content_offset.x, -content_offset.y);
+ let masked = crate::elements::rounded_content_buffer(&mut self.rounded_content_buffers, &mut udev.renderer, epoch, id, &surface, loc, content_size, w.corner_radius as f32, corners);
+ if let Some(buffer) = masked {
+ match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, (pos.0 as f64, pos.1 as f64), buffer, Some(w.opacity), None, None, Kind::Unspecified) {
Ok(elem) => rounded_elem = Some(elem),
Err(e) => log::warn!("udev: failed to import rounded content buffer: {e}"),
}
@@ -398,7 +733,7 @@ impl CompState {
}
}
}
- occluders.push(geom);
+ occluders.push(frame);
}
// Background/bottom layer-shell (wallpaper engines) last --
// bottommost, matching smithay's own `space_render_elements`
@@ -406,7 +741,6 @@ impl CompState {
custom_elements.extend(crate::elements::output_layer_elements(
&mut udev.renderer,
&output,
- (origin.x, origin.y),
|layer| matches!(layer, Layer::Background | Layer::Bottom),
));
}
@@ -517,7 +851,7 @@ impl CompState {
};
if has_damage {
let head = &mut udev.heads[index];
- if let Err(e) = head.copy_and_flip(&udev.card, back) {
+ if let Err(e) = head.copy_and_flip(&udev.card, back, &damage_rects) {
// Backed off, not retried on the very next poll tick --
// see `UdevHead::flip_retry_after`'s own doc comment for
// the real, live-reproduced incident this prevents: a
@@ -557,7 +891,7 @@ impl CompState {
// reason not to redraw at whatever rate this loop cycled,
// forever, since it kept getting told a new frame was
// wanted whether or not the screen had changed at all.
- presented.push((output, damage_rects));
+ presented.push((output, origin, damage_rects));
}
}
@@ -571,7 +905,7 @@ impl CompState {
}
// Frame callbacks + lock confirmation, once the `udev` borrow is done.
- for (output, damage_rects) in presented {
+ for (output, origin, damage_rects) in presented {
if locked {
let surface = self.lock_surface_for(&output).cloned();
crate::lock::send_lock_frame(surface.as_ref(), &output, elapsed);
@@ -579,7 +913,7 @@ impl CompState {
} else {
let out = output.clone();
let scale = Scale::from(out.current_scale().fractional_scale());
- for w in crate::elements::windows_touched_by_damage(&self.space, &damage_rects, scale) {
+ for w in crate::elements::windows_touched_by_damage(&self.space, &damage_rects, origin, scale) {
w.send_frame(&out, elapsed, None, |_, _| Some(out.clone()));
}
}
diff --git a/crates/wayland/src/udev/session.rs b/crates/wayland/src/udev/session.rs
index 42805b5..e39c578 100644
--- a/crates/wayland/src/udev/session.rs
+++ b/crates/wayland/src/udev/session.rs
@@ -89,9 +89,17 @@ pub(crate) fn register_session_notifier(handle: &LoopHandle<'static, CompState>,
// Some drivers reset mode-setting state across a VT
// switch; reassert every head before rendering again.
+ //
+ // The real connector and mode, not an empty connector
+ // list and no mode - that shape is DRM/KMS's own way
+ // to *disable* a CRTC, not reassert it, and was
+ // confirmed live to leave the screen black after
+ // switching back with no further VT switch, either
+ // direction, able to recover it. See `UdevHead::mode`'s
+ // own doc comment.
for head in &mut udev.heads {
let fb = head.buffers[head.front].fb;
- if let Err(e) = card.set_crtc(head.crtc, Some(fb), (0, 0), &[], None) {
+ if let Err(e) = card.set_crtc(head.crtc, Some(fb), (0, 0), &[head.connector], Some(head.mode)) {
log::warn!("udev: failed to reassert crtc on resume: {e}");
}
// Force a full repaint: contents are undefined after
@@ -140,10 +148,41 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB
let Some(udev) = state.udev.as_mut() else { return };
let delta = event.delta();
// Clamped to the union of every head, so the pointer travels
- // between monitors instead of stopping at the first one's edge.
- let (w, h) = udev.bounds();
- udev.pointer_pos.x = (udev.pointer_pos.x + delta.x).clamp(0.0, (w - 1.0).max(0.0));
- udev.pointer_pos.y = (udev.pointer_pos.y + delta.y).clamp(0.0, (h - 1.0).max(0.0));
+ // between monitors instead of stopping at the first one's edge
+ // - `min_x`/`min_y`, not a hardcoded `0.0` floor, so a head
+ // placed at a negative origin (a real "extend left"/"extend
+ // above" arrangement) is actually reachable. See `bounds`'s own
+ // doc comment for the live bug this fixes.
+ let (min_x, min_y, max_x, max_y) = udev.bounds();
+ udev.pointer_pos.x = (udev.pointer_pos.x + delta.x).clamp(min_x, (max_x - 1.0).max(min_x));
+ udev.pointer_pos.y = (udev.pointer_pos.y + delta.y).clamp(min_y, (max_y - 1.0).max(min_y));
+ let pos = udev.pointer_pos;
+ handle_pointer_position(state, pos, event.time_msec());
+ }
+ // Absolute-positioning devices (a touchscreen, a drawing tablet,
+ // and - confirmed live via a `WAYLAND_DEBUG=1` trace from a peer
+ // session - ydotool's virtual uinput device, used throughout this
+ // whole debugging effort) had no handler here at all: this match
+ // only ever covered `PointerMotion` (relative deltas), so every
+ // `PointerMotionAbsolute` event fell through to the catch-all
+ // below and was silently dropped. The winit (nested) backend
+ // already handles this exact event via `event.position_transformed`
+ // (see `winit/events.rs`'s matching arm); this is that same
+ // pattern for the bare-metal backend, which never got it. Uses the
+ // same union-of-every-head bounds `PointerMotion` above clamps
+ // into, so a single absolute-positioning device still addresses
+ // the whole multi-monitor span, not just the first head.
+ InputEvent::PointerMotionAbsolute { event } => {
+ let Some(udev) = state.udev.as_mut() else { return };
+ // `position_transformed` maps the device's own normalized
+ // [0,1] position into a `(0, 0)`-anchored size - offset by
+ // `min_x`/`min_y` afterward, same reasoning as `PointerMotion`
+ // above, so this still addresses a negative-origin head.
+ let (min_x, min_y, max_x, max_y) = udev.bounds();
+ let size = Size::from(((max_x - min_x) as i32, (max_y - min_y) as i32));
+ let pos = event.position_transformed(size);
+ udev.pointer_pos.x = (pos.x + min_x).clamp(min_x, (max_x - 1.0).max(min_x));
+ udev.pointer_pos.y = (pos.y + min_y).clamp(min_y, (max_y - 1.0).max(min_y));
let pos = udev.pointer_pos;
handle_pointer_position(state, pos, event.time_msec());
}
diff --git a/crates/wayland/src/winit/capture.rs b/crates/wayland/src/winit/capture.rs
index a96cff9..3b1d38e 100644
--- a/crates/wayland/src/winit/capture.rs
+++ b/crates/wayland/src/winit/capture.rs
@@ -29,7 +29,7 @@ impl WaylandPlatform {
let hide_top_layers = self.wm.borrow().visible_windows_front_to_back().any(|w| w.fullscreen);
let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new();
if !hide_top_layers {
- custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, (0, 0), |layer| matches!(layer, Layer::Top | Layer::Overlay)));
+ custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Top | Layer::Overlay)));
}
for id in self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect::<Vec<_>>() {
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
@@ -41,11 +41,22 @@ impl WaylandPlatform {
if let Some(dwindow) = self.state.id_to_window.get(&id) {
if let Some(surface) = crate::elements::window_wl_surface(dwindow) {
let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 };
- custom_elements.extend(crate::elements::surface_content_elements(renderer, &surface, (w.geometry.x, w.geometry.y + band), w.opacity));
+ // `content_offset`: same `xdg_surface.set_window_geometry`
+ // subtraction every other render/capture path in this
+ // codebase already does (`udev/render.rs`, `winit/
+ // render.rs`, `udev/capture.rs`) - missed here
+ // specifically. A CSD client's invisible shadow margin
+ // landed at `w.geometry.x, w.geometry.y + band` instead
+ // of its real visible content, so a screenshot taken on
+ // this backend showed the same content_offset-sized gap
+ // the on-screen render loops already had fixed.
+ let content_offset = dwindow.geometry().loc;
+ let pos = (w.geometry.x - content_offset.x, w.geometry.y + band - content_offset.y);
+ custom_elements.extend(crate::elements::surface_content_elements(renderer, &surface, pos, w.opacity));
}
}
}
- custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, (0, 0), |layer| matches!(layer, Layer::Background | Layer::Bottom)));
+ custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Background | Layer::Bottom)));
// A throwaway damage tracker, so this pass always draws the whole
// scene (age 0) and never perturbs the on-screen tracker's history.
diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs
index d18a489..c748ef4 100644
--- a/crates/wayland/src/winit/connect.rs
+++ b/crates/wayland/src/winit/connect.rs
@@ -113,6 +113,7 @@ impl WaylandPlatform {
_screencopy_state: screencopy::ScreencopyState::new::<CompState>(&dh),
screencopy_pending: Vec::new(),
_appmenu_state: crate::appmenu::AppmenuManagerState::new::<CompState>(&dh),
+ _virtual_keyboard_state: smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState::new::<CompState, _>(&dh, |_client| true),
_foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&dh),
foreign_toplevel_managers: Vec::new(),
foreign_toplevel_handles: HashMap::new(),
@@ -142,6 +143,7 @@ impl WaylandPlatform {
last_broadcast_workspace: None,
lock: SessionLock::default(),
cursor_status: smithay::input::pointer::CursorImageStatus::default_named(),
+ decoration_cursor_active: false,
cursor_buffers: crate::cursor::make_buffers(),
last_titlebar_click: None,
gesture_swipe: None,
@@ -159,12 +161,15 @@ impl WaylandPlatform {
border_top_decorations: HashMap::new(),
border_bottom_decorations: HashMap::new(),
decoration_signatures: HashMap::new(),
+ hovered_titlebar_button: None,
shadow_buffers: HashMap::new(),
rounded_corners_program,
content_epoch: HashMap::new(),
rounded_content_buffers: HashMap::new(),
border_side_buffers: HashMap::new(),
+ color_filter_buffers: HashMap::new(),
last_synced_size: HashMap::new(),
+ pending_size_configure: HashMap::new(),
pending: pending.clone(),
bound_keys: Rc::new(bound_keys.iter().cloned().collect()),
repeat_keys: Rc::new(repeat_keys.iter().cloned().collect()),
diff --git a/crates/wayland/src/winit/mod.rs b/crates/wayland/src/winit/mod.rs
index 707055d..6a7bac0 100644
--- a/crates/wayland/src/winit/mod.rs
+++ b/crates/wayland/src/winit/mod.rs
@@ -19,6 +19,7 @@ use smithay::backend::input::{
};
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
+use smithay::backend::renderer::element::solid::SolidColorBuffer;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::backend::renderer::ImportDma;
@@ -94,6 +95,6 @@ const TARGET_FRAME_TIME: Duration = Duration::from_micros(1_000_000 / 60);
mod capture;
mod connect;
mod events;
-mod platform;
+mod nested_platform;
mod render;
mod run;
diff --git a/crates/wayland/src/winit/platform.rs b/crates/wayland/src/winit/nested_platform.rs
index 96adde8..330ef91 100644
--- a/crates/wayland/src/winit/platform.rs
+++ b/crates/wayland/src/winit/nested_platform.rs
@@ -48,9 +48,15 @@ impl Platform for WaylandPlatform {
// `WindowManager`, never `state.space`, so an IPC focus
// change left rendering/hit-testing on the stale topmost
// window until something else happened to raise it.
+ //
+ // `raise_in_space`, not `focus_window` - see that
+ // function's doc comment: the full version re-runs the
+ // workspace-follow side effect on the already-focused
+ // window and silently reverts an `activate_workspace` IPC
+ // dispatch from the same cycle.
let focused = self.wm.borrow().focused_id();
if let Some(id) = focused {
- crate::input::focus_window(&mut self.state, id);
+ crate::input::raise_in_space(&mut self.state, id);
}
}
}
diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs
index 4d14649..5784520 100644
--- a/crates/wayland/src/winit/render.rs
+++ b/crates/wayland/src/winit/render.rs
@@ -4,6 +4,7 @@ impl WaylandPlatform {
pub(super) fn render_frame(&mut self) -> PlatformResult<()> {
self.state.tick_animations();
+ self.state.tick_hover_glyph_animation();
self.state.tick_dirty_broadcasts();
let size = self.backend.window_size();
let resized = self.output.current_mode().map(|m| m.size) != Some(size);
@@ -95,6 +96,20 @@ impl WaylandPlatform {
// rounded-content push (further down) uses `WinitElement::Rounded`
// directly.
let mut custom_elements: Vec<crate::rounded_corners::WinitElement> = Vec::new();
+ // Night light/reading mode - pushed first (topmost) so it colours
+ // everything else, including the context menu below: this backend
+ // draws no cursor of its own to exempt (unlike udev/render.rs's
+ // matching push), so there's nothing that needs to stay above it.
+ // See `color_filter::render_element` for why this is a translucent
+ // overlay rather than a true per-pixel shader.
+ {
+ let color_filter = self.wm.borrow().color_filter;
+ let output_name = self.output.name();
+ let buf = self.state.color_filter_buffers.entry(output_name).or_insert_with(SolidColorBuffer::default);
+ if let Some(elem) = crate::color_filter::render_element(buf, color_filter, (size.w, size.h)) {
+ custom_elements.push(crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Solid(elem)));
+ }
+ }
// The right-click titlebar menu, if open - pushed first so it's
// topmost over every window (this backend draws no cursor of its
// own, see this module's doc comment, so there's no "stay under
@@ -158,7 +173,7 @@ impl WaylandPlatform {
let rounded_corners_enabled = self.wm.borrow().rounded_corners_enabled.unwrap_or(true);
if !hide_top_layers {
custom_elements.extend(
- crate::elements::output_layer_elements(renderer, &self.output, (0, 0), |layer| matches!(layer, Layer::Top | Layer::Overlay))
+ crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Top | Layer::Overlay))
.into_iter()
.map(crate::rounded_corners::WinitElement::Base),
);
@@ -181,44 +196,18 @@ impl WaylandPlatform {
// (reported live as the border "not flush" with the window
// during an animated maximize/fullscreen/open-slide transition).
let geom = self.state.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry);
- // Same reasoning as udev/render.rs's matching push: positioned
- // from `geom`, not `w.geometry`. Fragment-clipped against
- // `occluders` now, same as the titlebar/border below - a
- // shadow used to draw in full regardless of what was stacked
- // in front of it (its own doc comment argued the low opacity
- // made that read as "a soft edge, not the hard-line bleed-
- // through that made the titlebar/border need it"), but that
- // reasoning only holds along a shadow's straight edges: the
- // corner regions use Chebyshev (square-ring), not radial,
- // falloff (see `shadow_bitmap`'s own doc comment), so a
- // shadow's corner is a hard-edged square block at up to
- // `SHADOW_MAX_ALPHA` (~35%) opacity, not a soft radial
- // vignette - reported live as a small dark rectangular patch
- // sitting on top of whatever window a floating/cascaded
- // window's own corner happened to overlap, most visible
- // exactly where two windows' corners nearly meet, which this
- // compositor's default cascade placement does constantly.
- if let Some(shadow) = self.state.shadow_buffers.get(&id) {
- let rect = decoration::shadow_rect(geom);
- for fragment in crate::elements::visible_border_fragments(rect, &occluders) {
- let pos = (fragment.x as f64, fragment.y as f64);
- let src = Rectangle::new(
- Point::from(((fragment.x - rect.x) as f64, (fragment.y - rect.y) as f64)),
- Size::from((fragment.width as f64, fragment.height as f64)),
- );
- match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, shadow, None, Some(src), None, Kind::Unspecified) {
- Ok(elem) => custom_elements.push(crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Memory(elem))),
- Err(e) => log::warn!("failed to import shadow buffer for window {id}: {e}"),
- }
- }
- }
+ // `geom` is this compositor's own request/target; `frame`
+ // corrects its far edge to match what the client's surface
+ // really committed - see `effective_frame`'s own doc comment
+ // and the matching comment in `udev/render.rs`'s render loop.
+ let frame = self.state.effective_frame(id, geom);
if let Some(deco) = self.state.decorations.get(&id) {
// Fragment-clipped, same as udev/render.rs's matching titlebar
// push - see that comment for why all-or-nothing (skip
// only once *fully* covered) wasn't enough: a titlebar
// only partially covered, the common case for cascaded
// windows, still bled through the covered part.
- let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT);
+ let titlebar_rect = srdwm_core::Rect::new(frame.x, frame.y, frame.width, srdwm_core::TITLEBAR_HEIGHT);
for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) {
let pos = (fragment.x as f64, fragment.y as f64);
let src = Rectangle::new(
@@ -235,9 +224,14 @@ impl WaylandPlatform {
// `decoration::border_strips`), so they never overlap this same
// window's own decoration/content pixels - draw order relative
// to those doesn't matter, only relative to other windows'.
+ // (The left/right strips *do* still need cropping against the
+ // top/bottom strip's own extended curve - see that crop's own
+ // doc comment further down; that's an overlap between two
+ // pieces of this window's own decoration, not with its content,
+ // so it doesn't contradict this paragraph.)
if w.border_width > 0 {
- let color = crate::state::effective_border_color(w.border_color, focused == Some(id));
- let strips = decoration::border_strips(geom, w.border_width);
+ let color = crate::state::effective_border_color(w.border_color, focused == Some(id), self.wm.borrow().theme.border_inactive_dim);
+ let strips = decoration::border_strips(frame, w.border_width);
// Strips 0/1 (top/bottom) are rounded on their own two
// corners - see `render_border_top`/`render_border_bottom`'s
// doc comments - so both are cached bitmaps (rebuilt only in
@@ -253,7 +247,15 @@ impl WaylandPlatform {
// all-or-nothing occlusion check.
if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() {
if let Some(buffer) = self.state.border_top_decorations.get(&id) {
- match MemoryRenderBufferRenderElement::from_buffer(renderer, (strips[0].x as f64, strips[0].y as f64), buffer, None, None, None, Kind::Unspecified) {
+ // See `decoration::border_top_visible_rows`'s own
+ // doc comment: an undecorated window has no
+ // titlebar band to safely absorb this buffer's own
+ // corner-curve-only extra rows, so they're cropped
+ // away instead of landing on real content.
+ let (row0, rows, shift) = decoration::border_top_visible_rows(w.decorated, w.border_width, w.corner_radius);
+ let pos = (strips[0].x as f64, (strips[0].y + shift as i32) as f64);
+ let src = Some(Rectangle::new(Point::from((0.0, row0 as f64)), Size::from((strips[0].width as f64, rows as f64))));
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, src, None, Kind::Unspecified) {
Ok(elem) => custom_elements.push(crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Memory(elem))),
Err(e) => log::warn!("failed to import top border buffer for window {id}: {e}"),
}
@@ -264,15 +266,39 @@ impl WaylandPlatform {
// render_border_bottom`'s doc comment.
if strips[1].width > 0 && strips[1].height > 0 && !strips[1].subtract_all(&occluders).is_empty() {
if let Some(buffer) = self.state.border_bottom_decorations.get(&id) {
- match MemoryRenderBufferRenderElement::from_buffer(renderer, (strips[1].x as f64, strips[1].y as f64), buffer, None, None, None, Kind::Unspecified) {
+ // See `decoration::border_bottom_visible_rows`'s
+ // own doc comment.
+ let (row0, rows, shift) = decoration::border_bottom_visible_rows(w.decorated, w.border_width, w.corner_radius);
+ let pos = (strips[1].x as f64, (strips[1].y - shift as i32) as f64);
+ let src = Some(Rectangle::new(Point::from((0.0, row0 as f64)), Size::from((strips[1].width as f64, rows as f64))));
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, src, None, Kind::Unspecified) {
Ok(elem) => custom_elements.push(crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Memory(elem))),
Err(e) => log::warn!("failed to import bottom border buffer for window {id}: {e}"),
}
}
}
+ // Cropped top and bottom by `extra` - see the matching fix
+ // (and its own doc comment) in `udev/render.rs`'s identical
+ // side-strip loop: the top/bottom strip's own curve extends
+ // `corner_radius - border_width` rows into what would
+ // otherwise be these flat, curve-unaware side strips' own
+ // nominal top/bottom rows, and without this crop their
+ // solid fill bled through the curve's own transparent
+ // cutout as a straight vertical line poking out of an
+ // otherwise correctly-rounded corner - reported live,
+ // confirmed via raw pixel sampling on the udev backend;
+ // this backend shares the identical strip geometry and was
+ // never actually confirmed clean, just never specifically
+ // screenshotted the same way.
+ let extra = if w.decorated { w.border_width.max(w.corner_radius).saturating_sub(w.border_width) } else { 0 };
+ let mut side_strips = [strips[2], strips[3]];
+ for s in &mut side_strips {
+ s.y += extra as i32;
+ s.height = s.height.saturating_sub(2 * extra);
+ }
let pool = self.state.border_side_buffers.entry(id).or_default();
let mut buf_index = 0;
- for strip in &strips[2..] {
+ for strip in &side_strips {
if strip.width == 0 || strip.height == 0 {
continue;
}
@@ -283,6 +309,45 @@ impl WaylandPlatform {
}
}
}
+ // Shadow - pushed *after* the titlebar/border above, not
+ // before. See the matching fix (and its full explanation) in
+ // `udev/render.rs`'s render loop: `custom_elements` treats
+ // earlier-pushed as topmost, so a shadow pushed before this
+ // window's own border rendered on top of it, alpha-blending
+ // black over the configured border colour and muting it into a
+ // hazy smear instead of a crisp line - reported live as
+ // "spacing before the border". Positioned from `geom`, not
+ // `w.geometry`, same reasoning as the border above (a stale-
+ // position shadow during an animated tween looks as detached
+ // as the border did before that fix).
+ //
+ // Fragment-clipped against `occluders` now, same as the
+ // titlebar/border above - this used to skip that on the
+ // reasoning that `SHADOW_MAX_ALPHA`'s low opacity would read
+ // as a soft edge, not the hard-line bleed-through that made
+ // the titlebar/border need it. True along a shadow's straight
+ // edges, false at its corners: `shadow_bitmap` falls off by
+ // Chebyshev (square-ring) distance, not radial, so each corner
+ // is a hard-edged square block at up to ~35% opacity, not a
+ // soft vignette - reported live as a small dark rectangular
+ // patch sitting on top of whatever window a floating/cascaded
+ // window's own corner happened to overlap, most visible
+ // exactly where two windows' corners nearly meet, which this
+ // compositor's default cascade placement does constantly.
+ if let Some(shadow) = self.state.shadow_buffers.get(&id) {
+ let rect = decoration::shadow_rect(frame);
+ for fragment in crate::elements::visible_border_fragments(rect, &occluders) {
+ let pos = (fragment.x as f64, fragment.y as f64);
+ let src = Rectangle::new(
+ Point::from(((fragment.x - rect.x) as f64, (fragment.y - rect.y) as f64)),
+ Size::from((fragment.width as f64, fragment.height as f64)),
+ );
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, shadow, None, Some(src), None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Memory(elem))),
+ Err(e) => log::warn!("failed to import shadow buffer for window {id}: {e}"),
+ }
+ }
+ }
// The window's own content, at its own `opacity` - see the
// matching push in `udev/render.rs`'s render loop for why. Single
// output at the global origin, so no offset to subtract (see
@@ -320,13 +385,13 @@ impl WaylandPlatform {
}
}
}
- occluders.push(geom);
+ occluders.push(frame);
}
// Background/bottom layer-shell (wallpaper engines) last --
// bottommost, matching smithay's own `space_render_elements`
// ordering, which this whole custom loop now replaces.
custom_elements.extend(
- crate::elements::output_layer_elements(renderer, &self.output, (0, 0), |layer| matches!(layer, Layer::Background | Layer::Bottom))
+ crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Background | Layer::Bottom))
.into_iter()
.map(crate::rounded_corners::WinitElement::Base),
);
@@ -379,7 +444,7 @@ impl WaylandPlatform {
self.backend.submit(None).map_err(err)?;
let scale = Scale::from(self.output.current_scale().fractional_scale());
let now = self.state.start_time.elapsed();
- for w in crate::elements::windows_touched_by_damage(&self.state.space, &damage_rects, scale) {
+ for w in crate::elements::windows_touched_by_damage(&self.state.space, &damage_rects, (0, 0).into(), scale) {
w.send_frame(&self.output, now, None, |_, _| Some(self.output.clone()));
}
}
diff --git a/crates/wayland/src/xwayland.rs b/crates/wayland/src/xwayland.rs
index d8c56a3..a51ee97 100644
--- a/crates/wayland/src/xwayland.rs
+++ b/crates/wayland/src/xwayland.rs
@@ -67,7 +67,33 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm
log::warn!("could not set up an -shm wrapper for XWayland ({e}); XWayland windows will likely fail to render - see xwayland.rs's `spawn` docs");
}
- let (xwayland, client) = XWayland::spawn(display_handle, None, std::iter::empty::<(String, String)>(), true, std::process::Stdio::null(), std::process::Stdio::null(), |_| ())?;
+ // Xwayland's own stdout/stderr, not `/dev/null` - a real session hit
+ // XWayland never becoming ready at all (no `Ready`, no `Error`, no
+ // process left running, `com.canonical.AppMenu.Registrar` left
+ // permanently unclaimed as one downstream symptom of it) with
+ // *nothing* logged anywhere to explain why, because whatever Xwayland
+ // itself would have printed about the failure was being thrown away
+ // right here. A manual, standalone run of the exact same binary (and
+ // of the `-shm` wrapper `ensure_shm_wrapper_on_path` installs) both
+ // succeeded outside this process, which points at something specific
+ // to *this* process's environment/context rather than the binary
+ // itself - but confirming that needs Xwayland's own words, not
+ // another guess. Redirected to a file rather than piped and read back
+ // in-process: a real stdout/stderr handle Xwayland can just write to
+ // synchronously, no async plumbing needed for a diagnostic that's
+ // meant to be read after the fact, not reacted to live.
+ let xwayland_log = xwayland_log_path();
+ if let Some(dir) = xwayland_log.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ let stdio = |path: &std::path::Path| -> std::process::Stdio {
+ std::fs::OpenOptions::new().create(true).append(true).open(path).map(std::process::Stdio::from).unwrap_or_else(|e| {
+ log::warn!("xwayland: couldn't open {path:?} for Xwayland's own stdout/stderr ({e}); falling back to /dev/null");
+ std::process::Stdio::null()
+ })
+ };
+ let (xwayland, client) =
+ XWayland::spawn(display_handle, None, std::iter::empty::<(String, String)>(), true, stdio(&xwayland_log), stdio(&xwayland_log), |_| ())?;
let handle_for_ready = handle.clone();
handle
@@ -335,6 +361,61 @@ impl EwmhState {
Some(srdwm_core::GlobalMenu { bus_name, menu_path, app_path, window_path, source })
}
+ /// Selects `PropertyChangeMask` on `xid` - without this, the X server
+ /// never sends this connection a `PropertyNotify` for it at all, no
+ /// matter what changes. Call once, right after a window finishes
+ /// setup; see `poll_property_events`'s own doc comment for why this is
+ /// needed on top of `update_net_active_window`'s focus-triggered read.
+ fn watch_property_changes(&self, xid: u32) {
+ use smithay::reexports::x11rb::connection::Connection;
+ use smithay::reexports::x11rb::protocol::xproto::{ChangeWindowAttributesAux, ConnectionExt as _, EventMask};
+ if let Err(e) = self.conn.change_window_attributes(xid, &ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE)) {
+ log::warn!("xwayland: couldn't watch xid={xid} for global-menu property changes: {e}");
+ return;
+ }
+ let _ = self.conn.flush();
+ }
+
+ /// Every xid, watched via `watch_property_changes`, whose global-menu
+ /// atom changed since the last call - non-blocking, `poll_for_event`
+ /// never waits on the network.
+ ///
+ /// Needed on top of `update_net_active_window`'s per-focus-change read:
+ /// that read only fires when a window *gains* focus, but most toolkits
+ /// set `_GTK_UNIQUE_BUS_NAME`/the menu-path atom once, shortly after
+ /// mapping - for an already-focused window (the common case: a freshly
+ /// launched app almost always opens focused) that registration can
+ /// finish *after* the one focus-triggered read already ran, leaving
+ /// `Window.global_menu` stuck at `None` until the user clicks away and
+ /// back. Reported live as "global menu doesn't show up for some
+ /// windows" - this is why it was intermittent rather than affecting
+ /// every window the same way: it depended on a race between window-map
+ /// and D-Bus registration that a plain focus-change hook has no way to
+ /// see.
+ fn poll_property_events(&self) -> Vec<u32> {
+ use smithay::reexports::x11rb::connection::Connection;
+ use smithay::reexports::x11rb::protocol::Event;
+ let menu_atoms = [
+ self.gtk_unique_bus_name,
+ self.gtk_application_object_path,
+ self.gtk_window_object_path,
+ self.gtk_menubar_object_path,
+ self.gtk_app_menu_object_path,
+ self.unity_object_path,
+ self.kde_appmenu_service_name,
+ self.kde_appmenu_object_path,
+ ];
+ let mut xids = Vec::new();
+ while let Ok(Some(event)) = self.conn.poll_for_event() {
+ if let Event::PropertyNotify(n) = event {
+ if menu_atoms.contains(&Some(n.atom)) && !xids.contains(&n.window) {
+ xids.push(n.window);
+ }
+ }
+ }
+ xids
+ }
+
/// `xid` is `None` when focus is on a native Wayland window (or
/// nothing) rather than an X11 one - `_NET_ACTIVE_WINDOW`'s value is
/// only meaningful for X11 clients, so this writes `0` (the documented
@@ -399,6 +480,25 @@ impl CompState {
}
}
+ /// Call once per event-loop tick (same cadence as
+ /// `apply_registrar_events`): applies every global-menu property change
+ /// `EwmhState::poll_property_events` picked up since the last call. See
+ /// that method's own doc comment for why this exists on top of the
+ /// focus-triggered read in `update_net_active_window`.
+ pub(crate) fn poll_global_menu_properties(&mut self) {
+ let xids = match &self.ewmh {
+ Some(ewmh) => ewmh.poll_property_events(),
+ None => return,
+ };
+ for xid in xids {
+ let Some(&id) = self.xwayland_windows.get(&xid) else { continue };
+ let menu = self.ewmh.as_ref().and_then(|ewmh| ewmh.read_global_menu(xid));
+ if let Some(w) = self.wm.borrow_mut().window_mut(id) {
+ w.global_menu = menu;
+ }
+ }
+ }
+
/// Drains `AppmenuRegistrarState`'s channel and applies every event to
/// the matching `Window.global_menu` - call once per event-loop tick
/// (`poll_events`), same as `IpcServer::poll`.
@@ -454,6 +554,18 @@ impl CompState {
/// the wrapper instead of the real binary. The wrapper always re-execs the
/// real `Xwayland` with `-shm` prepended to whatever arguments it was
/// given, so it's transparent to everything else `spawn` sets up.
+/// Where Xwayland's own stdout/stderr land - `$XDG_STATE_HOME/srd/
+/// xwayland.log` (`~/.local/state/srd/xwayland.log` fallback), same
+/// directory `monitor_layout.rs` already uses for this compositor's own
+/// state, appended to (not truncated) so a restart doesn't erase whatever
+/// the previous run's Xwayland process said right before this one starts.
+fn xwayland_log_path() -> std::path::PathBuf {
+ let dir = std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from).unwrap_or_else(|| {
+ std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/state")).unwrap_or_else(std::env::temp_dir)
+ });
+ dir.join("srd").join("xwayland.log")
+}
+
fn ensure_shm_wrapper_on_path() -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
@@ -573,6 +685,12 @@ impl CompState {
self.set_keyboard_focus(Some(wl_surface));
self.pending.borrow_mut().push(CoreEvent::WindowCreated(id));
self.update_net_client_list();
+ // See `poll_global_menu_properties`'s doc comment: without this,
+ // this connection never receives a `PropertyNotify` for this
+ // window at all, no matter what its global-menu atoms later do.
+ if let Some(ewmh) = &self.ewmh {
+ ewmh.watch_property_changes(surface.window_id());
+ }
crate::foreign_toplevel::window_created(self, id);
}