diff options
Diffstat (limited to 'crates/wayland')
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(¢ered).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); } |