diff options
| author | srdusr <[email protected]> | 2024-12-24 20:44:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-12-24 20:44:00 +0200 |
| commit | 3d73ed0f057a555ded67ad58dbe02b36cd4d2d1f (patch) | |
| tree | 71e9bab462b9073444183e2a969ba512cb94559f /crates/wayland/src | |
| parent | 331a1a1d3a0d8dfd4c285b755c2e13785c95ee2a (diff) | |
| download | srdwm-3d73ed0f057a555ded67ad58dbe02b36cd4d2d1f.tar.gz srdwm-3d73ed0f057a555ded67ad58dbe02b36cd4d2d1f.zip | |
Accumulate wayland-crate additions: capture rendering, focus/Space sync, VT resume, plumbing
Bundles the remaining wayland-crate changes built up here,
touching both backends (udev and winit) and the shared input/rendering
code:
- udev/capture.rs: off-screen Pixman render of an arbitrary (not
necessarily on-screen) workspace's window content to a PPM file --
what crates/core's capture-request queue drives, for a workspace
switcher's thumbnail previews. wlr-screencopy structurally can't do
this (it can only see what an output is presenting), which is why
this exists as a separate render path rather than reusing it.
- input::focus_window now also raises the window in smithay's own
Space, not just core's stacking order - Space is what actually
renders on top and what pointer hit-testing reads, so any focus path
that skipped this (an IPC "focus" dispatch, concretely) left a
window genuinely focused while still rendering, and receiving
clicks, underneath whatever was already topmost. Both backends'
poll loops now re-sync this after any IPC mutation.
- udev/session.rs's VT-switch resume fix (drains a stale pending page
flip before reasserting CRTCs) already has its own earlier, cleanly
isolated commit - not duplicated here.
- Assorted decoration/cursor/rounded-corners/output-management/
screencopy/XWayland changes and their cross-backend wiring.
Coarser than the repo's usual one-purpose-per-commit convention,
deliberately - see the core-crate sweep commit's own message for why.
Diffstat (limited to 'crates/wayland/src')
22 files changed, 1608 insertions, 268 deletions
diff --git a/crates/wayland/src/cursor.rs b/crates/wayland/src/cursor.rs index c03fcef..0d65e6a 100644 --- a/crates/wayland/src/cursor.rs +++ b/crates/wayland/src/cursor.rs @@ -12,23 +12,32 @@ //! 1. **The client's own cursor surface** (`CursorImageStatus::Surface`) -- //! a terminal's I-beam, a browser's hand, an app's resize arrows. Drawn //! from its surface tree, offset by the hotspot the client declared. -//! 2. **The system's real XCursor theme arrow** (`load_theme_arrow`), -//! resolved from `XCURSOR_THEME`/`XCURSOR_SIZE` or, failing that, GTK's -//! own `gtk-cursor-theme-name`/`-size` - for when no client has set an -//! image (over srdwm's own decorations and the desktop) or asked for a -//! named shape we have no art for. -//! 3. **A built-in hand-rasterized arrow**, only if theme loading found -//! nothing at all - no theme installed, an unreadable file, whatever. -//! A cursor that is always present beats a prettier one that sometimes -//! isn't there, the same reasoning as `decoration.rs`'s font fallback; -//! this is the same bitmap that used to be the *only* arrow. +//! 2. **The system's real XCursor theme** (`load_theme_cursor`), resolved +//! from `XCURSOR_THEME`/`XCURSOR_SIZE` or, failing that, GTK's own +//! `gtk-cursor-theme-name`/`-size` - for when no client has set an +//! image (over srdwm's own decorations and the desktop), tried for +//! every shape below, not just the plain arrow. +//! 3. **A built-in hand-rasterized shape**, only if theme loading found +//! nothing at all for that specific shape - no theme installed, an +//! unreadable file, or the theme genuinely has no icon under any of +//! the names tried. A cursor that is always present beats a prettier +//! one that sometimes isn't there, the same reasoning as `decoration. +//! rs`'s font fallback; this is the same set of bitmaps that used to +//! be the *only* option for every shape but the arrow. //! //! `CursorImageStatus::Hidden` is honoured, so a client that hides the //! pointer still gets its way. Named shapes we have dedicated art for //! (text entry, the four resize directions, crosshair, move, and the -//! pointing-hand link-hover shape) still render as that hand-drawn shape, -//! not a theme lookup - only the plain default arrow goes through theme -//! resolution. +//! pointing-hand link-hover shape) go through the *same* theme resolution +//! the arrow does - each tries a short list of the theme's own names for +//! that shape (`ew-resize`, `sb_h_double_arrow`, ... for the horizontal +//! resize cursor, say) before falling back to the hand-drawn bitmap. +//! Every shape used to skip straight to the hand-drawn version regardless +//! of what the theme actually shipped, which is what made them look +//! noticeably cruder than the arrow next to them - reported live as the +//! resize cursor in particular looking "hideous", and the pointer/move +//! shapes barely visible at all, while the plain arrow (already theme- +//! resolved) looked fine. use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; use smithay::utils::{Logical, Point}; @@ -170,18 +179,28 @@ fn add_white_halo(buf: &mut [u8]) { #[derive(Clone)] pub(crate) struct CursorBuffers { pub(crate) arrow: smithay::backend::renderer::element::memory::MemoryRenderBuffer, - /// `arrow`'s hotspot - (0, 0), the bitmap's top-left tip, for the - /// built-in fallback, but a real XCursor theme's own `xhot`/`yhot` when - /// `load_theme_arrow` found one. See that function's doc comment. + /// Every shape's hotspot travels with its buffer now, not just the + /// arrow's: a real theme cursor's `xhot`/`yhot` when `load_theme_ + /// cursor` found one for this shape, or the fixed built-in value + /// (`CENTERED_HOTSPOT`/`POINTER_HOTSPOT`/`(0, 0)`) when it fell back + /// to the hand-drawn bitmap - see `make_buffers`. pub(crate) arrow_hotspot: (i32, i32), pub(crate) text: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) text_hotspot: (i32, i32), pub(crate) ns_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) ns_resize_hotspot: (i32, i32), pub(crate) ew_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) ew_resize_hotspot: (i32, i32), pub(crate) nesw_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) nesw_resize_hotspot: (i32, i32), pub(crate) nwse_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) nwse_resize_hotspot: (i32, i32), pub(crate) crosshair: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) crosshair_hotspot: (i32, i32), pub(crate) move_icon: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) move_icon_hotspot: (i32, i32), pub(crate) pointer: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) pointer_hotspot: (i32, i32), } /// Sets one pixel to opaque white-on-black-outline isn't needed here (these @@ -368,35 +387,85 @@ fn upload(data: Vec<u8>) -> smithay::backend::renderer::element::memory::MemoryR MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (CURSOR_SIZE, CURSOR_SIZE), 1, Transform::Normal, None) } -/// The centered shapes' hotspot: dead center of the bitmap, unlike the -/// arrow's tip-at-origin. Shared by every shape built here except the -/// arrow itself. +/// Tries each of `names` against the resolved theme in order, uploads the +/// first that resolves; falls back to `built_in()` (drawn at the fixed +/// `hotspot_fallback`) if none of them do. One helper for all nine shapes +/// `make_buffers` builds, so every one of them gets the same "real theme +/// cursor first, hand-drawn shape only if the theme genuinely has nothing" +/// treatment the arrow alone used to get. +fn load_or_draw( + theme: &xcursor::CursorTheme, + size: u32, + names: &[&str], + built_in: impl Fn() -> Vec<u8>, + hotspot_fallback: (i32, i32), +) -> (smithay::backend::renderer::element::memory::MemoryRenderBuffer, (i32, i32)) { + use smithay::backend::allocator::Fourcc; + use smithay::backend::renderer::element::memory::MemoryRenderBuffer; + use smithay::utils::Transform; + match load_theme_cursor(theme, size, names) { + Some(tc) => (MemoryRenderBuffer::from_slice(&tc.bgra, Fourcc::Argb8888, tc.size, 1, Transform::Normal, None), tc.hotspot), + None => (upload(built_in()), hotspot_fallback), + } +} + +/// The centered shapes' fallback hotspot: dead center of the built-in +/// bitmap, unlike the arrow's tip-at-origin. Only used when a shape falls +/// back to the hand-drawn bitmap - a real theme cursor carries its own +/// `xhot`/`yhot` regardless of where that happens to fall. pub(crate) const CENTERED_HOTSPOT: (i32, i32) = (CURSOR_SIZE / 2, CURSOR_SIZE / 2); pub(crate) fn make_buffers() -> CursorBuffers { - let (arrow, arrow_hotspot) = match load_theme_arrow() { - Some(theme_arrow) => { - use smithay::backend::allocator::Fourcc; - use smithay::backend::renderer::element::memory::MemoryRenderBuffer; - use smithay::utils::Transform; - ( - MemoryRenderBuffer::from_slice(&theme_arrow.bgra, Fourcc::Argb8888, theme_arrow.size, 1, Transform::Normal, None), - theme_arrow.hotspot, - ) - } - None => (make_buffer(), (0, 0)), - }; + // Resolved once, not once per shape: `CursorTheme::load` re-walks the + // theme's `index.theme` inheritance chain and search paths every call, + // real (if small) work worth not repeating nine times over for what is + // - for every shape's own lookup - the exact same theme and size. + let (theme_name, size) = theme_and_size(); + let theme = xcursor::CursorTheme::load(&theme_name); + + let (arrow, arrow_hotspot) = load_or_draw(&theme, size, &["left_ptr", "default", "arrow"], arrow_bitmap, (0, 0)); + let (text, text_hotspot) = load_or_draw(&theme, size, &["text", "xterm"], text_bitmap, CENTERED_HOTSPOT); + let (ns_resize, ns_resize_hotspot) = load_or_draw( + &theme, + size, + &["ns-resize", "sb_v_double_arrow", "v_double_arrow", "size_ver", "size-ver", "row-resize"], + || straight_resize_bitmap(false), + CENTERED_HOTSPOT, + ); + let (ew_resize, ew_resize_hotspot) = load_or_draw( + &theme, + size, + &["ew-resize", "sb_h_double_arrow", "h_double_arrow", "size_hor", "size-hor", "col-resize"], + || straight_resize_bitmap(true), + CENTERED_HOTSPOT, + ); + let (nesw_resize, nesw_resize_hotspot) = + load_or_draw(&theme, size, &["nesw-resize", "size_bdiag", "size-bdiag", "ne-resize", "sw-resize"], || diagonal_resize_bitmap(false), CENTERED_HOTSPOT); + let (nwse_resize, nwse_resize_hotspot) = + load_or_draw(&theme, size, &["nwse-resize", "size_fdiag", "size-fdiag", "nw-resize", "se-resize"], || diagonal_resize_bitmap(true), CENTERED_HOTSPOT); + let (crosshair, crosshair_hotspot) = load_or_draw(&theme, size, &["crosshair", "cross", "tcross"], crosshair_bitmap, CENTERED_HOTSPOT); + let (move_icon, move_icon_hotspot) = load_or_draw(&theme, size, &["move", "fleur", "size_all", "all-scroll"], move_bitmap, CENTERED_HOTSPOT); + let (pointer, pointer_hotspot) = load_or_draw(&theme, size, &["pointer", "hand2", "pointing_hand", "hand1", "link"], pointer_bitmap, POINTER_HOTSPOT); + CursorBuffers { arrow, arrow_hotspot, - text: upload(text_bitmap()), - ns_resize: upload(straight_resize_bitmap(false)), - ew_resize: upload(straight_resize_bitmap(true)), - nesw_resize: upload(diagonal_resize_bitmap(false)), - nwse_resize: upload(diagonal_resize_bitmap(true)), - crosshair: upload(crosshair_bitmap()), - move_icon: upload(move_bitmap()), - pointer: upload(pointer_bitmap()), + text, + text_hotspot, + ns_resize, + ns_resize_hotspot, + ew_resize, + ew_resize_hotspot, + nesw_resize, + nesw_resize_hotspot, + nwse_resize, + nwse_resize_hotspot, + crosshair, + crosshair_hotspot, + move_icon, + move_icon_hotspot, + pointer, + pointer_hotspot, } } @@ -461,39 +530,30 @@ where return render_elements_from_surface_tree(renderer, surface, at, 1.0, 1.0, Kind::Cursor); } - // No client image. A named shape we have dedicated art for gets it - // (centered on the pointer - these are all symmetric shapes, unlike - // the arrow); anything else (Default, or one of the many shapes we - // still don't draw, e.g. Grab/Wait/Help/NotAllowed) falls back to the - // arrow. - // The arrow's own hotspot is `buffers.arrow_hotspot` - (0, 0), the - // bitmap's tip, for the built-in fallback, but a real theme's `xhot`/ - // `yhot` (not necessarily the top-left corner at all) when - // `load_theme_arrow` found one. - let (buffer, at) = match status { + // No client image. A named shape we have dedicated art for gets it; + // anything else (Default, or one of the many shapes we still don't + // draw, e.g. Grab/Wait/Help/NotAllowed) falls back to the arrow. + // Every shape's hotspot travels with its own buffer now (`make_buffers`) + // - a real theme cursor's `xhot`/`yhot` when one was found for that + // specific shape, the fixed `CENTERED_HOTSPOT`/`POINTER_HOTSPOT`/ + // `(0, 0)` fallback otherwise - rather than every non-arrow shape + // assuming the same centered point regardless of what actually got + // drawn. + let (buffer, hotspot) = match status { CursorImageStatus::Named(icon) => match icon { - CursorIcon::Text | CursorIcon::VerticalText => { - (&buffers.text, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) - } - CursorIcon::EResize | CursorIcon::WResize | CursorIcon::EwResize | CursorIcon::ColResize => { - (&buffers.ew_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) - } - CursorIcon::NResize | CursorIcon::SResize | CursorIcon::NsResize | CursorIcon::RowResize => { - (&buffers.ns_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) - } - CursorIcon::NeResize | CursorIcon::SwResize | CursorIcon::NeswResize => { - (&buffers.nesw_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) - } - CursorIcon::NwResize | CursorIcon::SeResize | CursorIcon::NwseResize | CursorIcon::AllResize => { - (&buffers.nwse_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) - } - CursorIcon::Crosshair => (&buffers.crosshair, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)), - CursorIcon::Move => (&buffers.move_icon, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)), - CursorIcon::Pointer => (&buffers.pointer, (local.0 - POINTER_HOTSPOT.0, local.1 - POINTER_HOTSPOT.1)), - _ => (&buffers.arrow, (local.0 - buffers.arrow_hotspot.0, local.1 - buffers.arrow_hotspot.1)), + CursorIcon::Text | CursorIcon::VerticalText => (&buffers.text, buffers.text_hotspot), + CursorIcon::EResize | CursorIcon::WResize | CursorIcon::EwResize | CursorIcon::ColResize => (&buffers.ew_resize, buffers.ew_resize_hotspot), + CursorIcon::NResize | CursorIcon::SResize | CursorIcon::NsResize | CursorIcon::RowResize => (&buffers.ns_resize, buffers.ns_resize_hotspot), + CursorIcon::NeResize | CursorIcon::SwResize | CursorIcon::NeswResize => (&buffers.nesw_resize, buffers.nesw_resize_hotspot), + CursorIcon::NwResize | CursorIcon::SeResize | CursorIcon::NwseResize | CursorIcon::AllResize => (&buffers.nwse_resize, buffers.nwse_resize_hotspot), + CursorIcon::Crosshair => (&buffers.crosshair, buffers.crosshair_hotspot), + CursorIcon::Move => (&buffers.move_icon, buffers.move_icon_hotspot), + CursorIcon::Pointer => (&buffers.pointer, buffers.pointer_hotspot), + _ => (&buffers.arrow, buffers.arrow_hotspot), }, - _ => (&buffers.arrow, (local.0 - buffers.arrow_hotspot.0, local.1 - buffers.arrow_hotspot.1)), + _ => (&buffers.arrow, buffers.arrow_hotspot), }; + let at = (local.0 - hotspot.0, local.1 - hotspot.1); let at = (at.0 as f64, at.1 as f64); match MemoryRenderBufferRenderElement::from_buffer(renderer, at, buffer, None, None, None, Kind::Cursor) { Ok(e) => vec![OverlayElement::Memory(e)], @@ -505,7 +565,7 @@ where } -/// Resolves which XCursor theme to load the real arrow from, and at what +/// Resolves which XCursor theme to load real cursors from, and at what /// size. /// /// `XCURSOR_THEME`/`XCURSOR_SIZE` are the standard override, but nothing @@ -547,15 +607,24 @@ fn theme_and_size() -> (String, u32) { } /// A loaded XCursor image, converted to what `make_buffers` needs to upload -/// it: BGRA8888 pixels, pixel dimensions, and hotspot. -struct ThemeArrow { +/// it: BGRA8888 pixels, pixel dimensions, and hotspot. Generic over which +/// shape it came from - was arrow-only (`ThemeArrow`) before every shape +/// started resolving through the theme. +struct ThemeCursor { bgra: Vec<u8>, size: (i32, i32), hotspot: (i32, i32), } -/// Loads the real arrow cursor (`left_ptr`) from the resolved XCursor theme, -/// picking whichever bundled nominal size is closest to the target. +/// Loads one named cursor (trying each of `names` in order, using the +/// first that resolves) from an already-resolved theme, picking whichever +/// bundled nominal size is closest to the target. Was arrow-only +/// (`load_theme_arrow`, a single hardcoded `"left_ptr"`) before every +/// shape started resolving through the theme - several themes only ship +/// the legacy X11 name for a given shape (`sb_h_double_arrow` rather than +/// the modern `ew-resize`, say), so trying a short list rather than one +/// fixed name is what makes this actually portable across themes, not +/// just the one installed here. /// /// Converts pixels from the crate's RGBA byte order (`Image::pixels_rgba`, /// straight off disk) to the BGRA order every buffer in this file uses for @@ -564,20 +633,20 @@ struct ThemeArrow { /// spec, same as every bitmap built here, so only the channel order needs /// converting, not the alpha itself. /// -/// Returns `None` on any failure - theme or icon not found, corrupt file, +/// Returns `None` on any failure - none of `names` found, a corrupt file, /// a pixel count that doesn't match the declared dimensions - so the -/// caller falls back to the built-in bitmap arrow, which is the entire -/// reason that fallback exists: see this module's own doc comment. -fn load_theme_arrow() -> Option<ThemeArrow> { - let (theme, size) = theme_and_size(); - let path = xcursor::CursorTheme::load(&theme).load_icon("left_ptr")?; +/// caller (`load_or_draw`) falls back to that shape's own hand-drawn +/// bitmap, which is the entire reason that fallback exists: see this +/// module's own doc comment. +fn load_theme_cursor(theme: &xcursor::CursorTheme, size: u32, names: &[&str]) -> Option<ThemeCursor> { + let path = names.iter().find_map(|name| theme.load_icon(name))?; let bytes = std::fs::read(&path).ok()?; let images = xcursor::parser::parse_xcursor(&bytes)?; let image = images.into_iter().min_by_key(|img| (img.size as i64 - size as i64).abs())?; if image.width == 0 || image.height == 0 || image.pixels_rgba.len() != (image.width * image.height * 4) as usize { return None; } - Some(ThemeArrow { + Some(ThemeCursor { bgra: rgba_to_bgra(&image.pixels_rgba), size: (image.width as i32, image.height as i32), hotspot: (image.xhot as i32, image.yhot as i32), @@ -601,17 +670,6 @@ fn rgba_to_bgra(pixels_rgba: &[u8]) -> Vec<u8> { bgra } -/// The built-in arrow as an uploadable buffer. Built once at startup rather -/// than per frame - the bitmap never changes. Kept as a fallback for when -/// `load_theme_arrow` finds nothing - see this module's doc comment on why -/// a cursor that's always present beats a prettier one that sometimes isn't. -pub(crate) fn make_buffer() -> smithay::backend::renderer::element::memory::MemoryRenderBuffer { - use smithay::backend::allocator::Fourcc; - use smithay::backend::renderer::element::memory::MemoryRenderBuffer; - use smithay::utils::Transform; - MemoryRenderBuffer::from_slice(&arrow_bitmap(), Fourcc::Argb8888, (CURSOR_SIZE, CURSOR_SIZE), 1, Transform::Normal, None) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs index 42d3231..36915e5 100644 --- a/crates/wayland/src/decoration.rs +++ b/crates/wayland/src/decoration.rs @@ -11,8 +11,8 @@ use fontdue::{Font, FontSettings}; use std::sync::OnceLock; -const FONT_PIXELS: f32 = 13.0; -const TEXT_LEFT_PADDING: f32 = 8.0; +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 @@ -26,7 +26,7 @@ const BUTTON_MARGIN: f32 = 0.32; /// 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. -fn find_system_font() -> Option<Font> { +pub(crate) fn find_system_font() -> Option<Font> { static FONT: OnceLock<Option<Font>> = OnceLock::new(); FONT.get_or_init(load_any_monospace_font).clone() } @@ -89,7 +89,7 @@ fn find_ttf_preferring_mono(dir: &std::path::Path, best: &mut Option<std::path:: } } -fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] { +pub(crate) fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] { [rgb.2, rgb.1, rgb.0, alpha] } @@ -158,6 +158,68 @@ pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)], buf } +/// The Snap-Layouts flyout (`crates/wayland/src/snap_flyout.rs`) - a +/// `columns`-wide grid of labeled cells, one per `SnapZoneKind`. Same +/// "deliberately plain" bar `render_context_menu` above sets: solid cells, +/// left-padded text, grid lines and an outer border in one colour - no +/// live preview thumbnails, no icons. +pub fn render_snap_flyout(columns: u32, cell_width: u32, cell_height: u32, labels: &[&str], bg: (u8, u8, u8), fg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec<u8> { + let (cell_width, cell_height, columns) = (cell_width.max(1) as usize, cell_height.max(1) as usize, columns.max(1) as usize); + let rows = labels.len().div_ceil(columns).max(1); + let width = cell_width * columns; + let height = cell_height * rows; + let mut buf = vec![0u8; width * height * 4]; + + let bg_px = rgb_to_bgra(bg, 255); + for px in buf.chunks_exact_mut(4) { + px.copy_from_slice(&bg_px); + } + + let font = find_system_font(); + for (i, label) in labels.iter().enumerate() { + let (col, row) = (i % columns, i / columns); + let (cell_x, cell_y) = (col * cell_width, row * cell_height); + if let Some(font) = &font { + let baseline = cell_y as f32 + cell_height as f32 * 0.58; + let mut pen_x = cell_x as f32 + TEXT_LEFT_PADDING; + for ch in label.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, bg, fg); + } + pen_x += metrics.advance_width; + if pen_x as usize >= cell_x + cell_width { + break; + } + } + } + } + + // Grid lines (including the outer border) drawn last, one colour, so + // nothing overdraws them. + let border_px = rgb_to_bgra(border, 255); + for y in 0..height { + for col in 0..=columns { + let x = (col * cell_width).min(width - 1); + let idx = y * width + x; + buf[idx * 4..idx * 4 + 4].copy_from_slice(&border_px); + } + } + for x in 0..width { + for row in 0..=rows { + let y = (row * cell_height).min(height - 1); + let idx = y * width + x; + buf[idx * 4..idx * 4 + 4].copy_from_slice(&border_px); + } + } + 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 @@ -283,14 +345,14 @@ fn edge_distance(pos: u32, margin: u32, extent: u32) -> u32 { /// 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)) -> Vec<u8> { +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, CORNER_RADIUS + thickness as u32); + round_top_corners(&mut buf, width, thickness, radius + thickness as u32); buf } @@ -306,14 +368,14 @@ pub fn render_border_top(width: u32, thickness: u32, color: (u8, u8, u8)) -> Vec /// 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)) -> Vec<u8> { +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, CORNER_RADIUS + thickness as u32); + round_bottom_corners(&mut buf, width, thickness, radius + thickness as u32); buf } @@ -331,7 +393,7 @@ pub fn render_border_bottom(width: u32, thickness: u32, color: (u8, u8, u8)) -> /// `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) -> Vec<u8> { +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]; @@ -371,23 +433,20 @@ pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8 draw_close_icon(&mut buf, width, height, 0, foreground); } if round_corners { - round_top_corners(&mut buf, width, height, CORNER_RADIUS); + round_top_corners(&mut buf, width, height, radius); } buf } -/// How many pixels of each top corner are clipped away by -/// `round_top_corners`. Small and fixed rather than configurable: this is a -/// cosmetic nicety, not a feature surface worth a `srd.theme` knob, and a -/// value this small barely reads as "rounded" if it gets any larger at the -/// titlebar heights this compositor actually uses. -/// -/// `pub(crate)`, not private: `rounded_corners.rs` reuses the exact same -/// value for a decorated window's own content (its bottom two corners, -/// where the GLES backend can round the client's actual pixels, unlike -/// the CPU bitmap clip this file does for the titlebar/border), so a -/// bordered window's curve reads as one continuous radius from titlebar -/// to content rather than two different ones meeting at a seam. +/// 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 @@ -405,6 +464,23 @@ pub(crate) const CORNER_RADIUS: u32 = 6; /// 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 { @@ -419,12 +495,12 @@ fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) { for y in 0..r { for x in 0..r { if is_outside_corner(x, y, r, r) { - buf[(y * width + x) * 4 + 3] = 0; + 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 + 3] = 0; + buf[(y * width + x) * 4..(y * width + x) * 4 + 4].fill(0); } } } @@ -432,7 +508,8 @@ fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) { /// [`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. +/// 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 { @@ -450,12 +527,12 @@ fn round_bottom_corners(buf: &mut [u8], width: usize, height: usize, radius: u32 for y in (height - r)..height { for x in 0..r { if is_outside_corner(x, y, r, cy) { - buf[(y * width + x) * 4 + 3] = 0; + 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 + 3] = 0; + buf[(y * width + x) * 4..(y * width + x) * 4 + 4].fill(0); } } } @@ -529,7 +606,7 @@ fn draw_minimize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: } #[allow(clippy::too_many_arguments)] -fn blit_glyph( +pub(crate) fn blit_glyph( buf: &mut [u8], width: usize, height: usize, @@ -621,7 +698,7 @@ mod tests { #[test] fn fills_background_when_no_text() { - let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true); + 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 @@ -644,7 +721,7 @@ mod tests { 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); + 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); @@ -671,7 +748,7 @@ mod tests { } let bg = (0x2e, 0x34, 0x40); let fg = (0xec, 0xef, 0xf4); - let buf = render_titlebar(200, 30, "Terminal", bg, fg, true); + 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"); @@ -681,7 +758,7 @@ mod tests { 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); + 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); @@ -697,7 +774,7 @@ mod tests { 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); + 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. @@ -713,10 +790,32 @@ mod tests { } #[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); + 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"); @@ -733,7 +832,7 @@ mod tests { // change now depends on. let color = (0x40, 0x50, 0x60); let (width, thickness) = (60, 2); - let buf = render_border_top(width, thickness, color); + 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"); @@ -748,7 +847,7 @@ mod tests { 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); + 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"); @@ -788,4 +887,36 @@ mod tests { 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/elements.rs b/crates/wayland/src/elements.rs index f7f7484..4c20ee6 100644 --- a/crates/wayland/src/elements.rs +++ b/crates/wayland/src/elements.rs @@ -12,10 +12,10 @@ use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRen use smithay::backend::renderer::element::surface::{render_elements_from_surface_tree, WaylandSurfaceRenderElement}; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::{Color32F, ImportAll, ImportMem, Renderer}; -use smithay::desktop::{layer_map_for_output, PopupManager, Space, Window as DWindow}; +use smithay::desktop::{layer_map_for_output, utils::under_from_surface_tree, PopupManager, Space, Window as DWindow, WindowSurfaceType}; use smithay::output::Output; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; -use smithay::utils::{Physical, Point, Rectangle, Scale}; +use smithay::utils::{Logical, Physical, Point, Rectangle, Scale}; use smithay::wayland::shell::wlr_layer::Layer; use srdwm_core::TITLEBAR_HEIGHT; @@ -167,25 +167,26 @@ where /// 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, smithay::backend::renderer::element::memory::MemoryRenderBuffer)>, + cache: &'a mut std::collections::HashMap<srdwm_core::WindowId, (u64, u32, smithay::backend::renderer::element::memory::MemoryRenderBuffer)>, epoch: u64, id: srdwm_core::WindowId, surface: &WlSurface, radius: f32, corners: crate::rounded_corners::RoundedCorners, ) -> Option<&'a smithay::backend::renderer::element::memory::MemoryRenderBuffer> { - let stale = cache.get(&id).map(|(built, _)| *built != epoch).unwrap_or(true); + let radius_bits = radius.to_bits(); + let stale = cache.get(&id).map(|(built, r, _)| *built != epoch || *r != radius_bits).unwrap_or(true); if stale { match crate::rounded_corners_pixman::masked_content_buffer(surface, radius, corners) { Some(buf) => { - cache.insert(id, (epoch, buf)); + cache.insert(id, (epoch, radius_bits, buf)); } 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` @@ -304,6 +305,49 @@ where elements } +/// Topmost currently-mapped popup (tooltip, dropdown, right-click menu) +/// under `pos`, if any - checked before every other kind of surface, the +/// same "draw on top of literally everything else" priority +/// `popup_render_elements` above already gives every popup (pushed into +/// `custom_elements` ahead of even `Overlay`/`Top` layer-shell surfaces in +/// both backends' render loops). +/// +/// Without this, pointer hit-testing (`input::refresh_pointer_focus`) never +/// checked popups at all: `state.popups: PopupManager` is entirely separate +/// from `state.space` (a popup is never `space.map_element`'d - see this +/// module's own `popup_render_elements` doc comment) and from layer-shell's +/// `LayerMap`, so hit-testing that only walked those two structures was +/// blind to every open popup. `xdg_popup`'s implicit pointer grab +/// (`PopupPointerGrab`, smithay's own - see `protocols.rs`'s `grab` +/// handler) only checks that the *focus* handed to it by `pointer.motion()` +/// belongs to the same client as the grabbed popup; it never substitutes in +/// the popup's own surface itself. A focus computed with no popup check at +/// all resolved to whatever was visually *underneath* the popup instead -- +/// typically the same client's own parent window, so the grab's same-client +/// check passed and let the motion straight through - meaning every click +/// or scroll over an open popup actually landed on the parent surface, at +/// coordinates that correspond to nothing real drawn there. Reads exactly +/// as "menus are hard to click or scroll in", even though neither the grab +/// itself nor the popup's own widgets were ever broken. +/// +/// Real per-pixel hit-testing via `under_from_surface_tree`, not a +/// bounding-rect check against `PopupKind::geometry()` - respects each +/// surface's actual input region and walks subsurfaces, the same +/// precision `layer_surface_under`/`Window::surface_under` already give +/// every other surface kind. +pub(crate) fn popup_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> { + for target in popup_targets(state).iter().rev() { + let popups: Vec<_> = PopupManager::popups_for_surface(&target.surface).collect(); + for (popup, offset) in popups.into_iter().rev() { + let origin = Point::<i32, Logical>::from(target.window_pos) + offset; + if let Some(hit) = under_from_surface_tree(popup.wl_surface(), pos, origin, WindowSurfaceType::ALL) { + return Some(hit); + } + } + } + None +} + /// Which of `space`'s mapped windows actually need a frame callback this /// pass - the ones whose on-screen bounds overlap `damage`, in physical /// output-space. diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs index af296db..fcffc19 100644 --- a/crates/wayland/src/input.rs +++ b/crates/wayland/src/input.rs @@ -140,34 +140,69 @@ fn notify_idle_activity(state: &mut CompState) { state.idle_notifier_state.notify_activity(&seat); } -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; - } - +/// 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>) { + // 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); - let over_layer_surface = layer_hit.is_some(); + // 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).map(|(w, loc)| (w.clone(), loc)); + 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, for `general.focus_follows_mouse` below - `None` while over - // a layer-shell surface or bare desktop, same as everything else here. + // 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 }; - if let Some((surface, loc)) = layer_hit { + let Some(pointer) = state.seat.get_pointer() else { return (hit, over_layer_surface, over_content, hovered_id) }; + if let Some((surface, loc)) = popup_hit { + let surface_loc = pos - loc.to_f64(); + pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); + } else if let Some((surface, loc)) = layer_hit { let surface_loc = pos - loc.to_f64(); pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); } else if hit.is_some() { @@ -201,6 +236,25 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log } else { pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); } + (hit, over_layer_surface, over_content, hovered_id) +} + +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 @@ -334,6 +388,25 @@ pub(crate) fn dwindow_wl_surface(w: &DWindow) -> Option<WlSurface> { 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() { @@ -348,6 +421,30 @@ pub(crate) fn close_dwindow(w: &DWindow) { /// 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 @@ -414,6 +511,18 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic } 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 @@ -499,11 +608,11 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic 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) { + if let Some((window, _loc)) = state.space.element_under(pos).filter(|(w, _)| dwindow_is_visible(state, w)) { let window = window.clone(); - state.space.raise_element(&window, true); - // Clicking a normal window must not bury a pinned one. - state.raise_pinned(); + // `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); } @@ -521,12 +630,15 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic // 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); - if let Some((id, TitlebarHit::Drag)) = hit { - if button == BTN_RIGHT { - state.open_context_menu(id, (pos.x as i32, pos.y as i32)); - } else { - state.wm.borrow_mut().lower_window(id); - } + 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(); @@ -569,6 +681,13 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic } } + // 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. + refresh_pointer_focus(state, pos, time); 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 }); @@ -597,14 +716,75 @@ pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend // (`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 |_, mods, handle| { + 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)) @@ -615,9 +795,17 @@ pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend 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 { - state.begin_repeat(keycode, &key_name, modifiers); - state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers }); + 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 @@ -674,15 +862,29 @@ where 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); - // Scrolling down (positive) advances, matching `workspace, e+1`. - let next = if v > 0.0 { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() }; + 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 @@ -690,14 +892,75 @@ where // 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; this gesture had the exact - // same bug already, just never one anyone traced back this far. + // 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 this gesture (or any other non-protocol trigger) changed 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/lib.rs b/crates/wayland/src/lib.rs index b218cf7..70e5c57 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -36,7 +36,10 @@ //! - xdg-decoration is forced to server-side mode (`Mode::ServerSide`) so //! well-behaved clients don't also draw their own client-side titlebar. +mod appmenu; +mod blur; mod context_menu; +mod snap_flyout; mod cursor; mod decoration; mod elements; @@ -46,6 +49,7 @@ mod gtk_shell; mod gtk_shell_protocol; mod input; mod lock; +mod native_lock; mod output_management; mod output_power; mod protocols; diff --git a/crates/wayland/src/output_management.rs b/crates/wayland/src/output_management.rs index 7849557..05040b9 100644 --- a/crates/wayland/src/output_management.rs +++ b/crates/wayland/src/output_management.rs @@ -327,7 +327,7 @@ 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. -fn apply_output_position(state: &mut CompState, output: &Output, new_location: Point<i32, smithay::utils::Logical>) { +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)); if let Some(entry) = state.outputs.iter_mut().find(|e| &e.output == output) { entry.location = new_location; diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs index bf14d5b..94075c2 100644 --- a/crates/wayland/src/protocols.rs +++ b/crates/wayland/src/protocols.rs @@ -131,6 +131,12 @@ impl CompositorHandler for CompState { *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` @@ -173,9 +179,18 @@ impl XdgShellHandler for CompState { /// 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) { - 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_drag(id, pos.x as i32, pos.y as i32); + // 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"), } } @@ -356,9 +371,17 @@ impl XdgShellHandler for 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(DecorationMode::ServerSide); + state.decoration_mode = Some(offer); }); } @@ -386,17 +409,19 @@ impl XdgDecorationHandler for CompState { } /// The client dropped its decoration-mode preference. `new_decoration` - /// already offered `ServerSide` as the default 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 `ClientSide`, then later unsets it expecting the default - /// back, would stay undecorated by us forever. + /// 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(DecorationMode::ServerSide); + state.decoration_mode = Some(mode); }); toplevel.send_configure(); - self.set_decorated_from_mode(toplevel.wl_surface(), true); + self.set_decorated_from_mode(toplevel.wl_surface(), default_decorated); } } @@ -667,8 +692,23 @@ impl WlrLayerShellHandler for CompState { // 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()) { - self.set_keyboard_focus(None); + crate::input::sync_keyboard_focus(self); } } } diff --git a/crates/wayland/src/rounded_corners.rs b/crates/wayland/src/rounded_corners.rs index 25fc199..4e02616 100644 --- a/crates/wayland/src/rounded_corners.rs +++ b/crates/wayland/src/rounded_corners.rs @@ -176,6 +176,18 @@ pub(crate) fn rounded_content_element( radius: f32, corners: RoundedCorners, ) -> Option<TextureShaderElement> { + // This module's doc comment has always said a window with subsurfaces + // "won't have them rendered at all through this path" - intended to + // mean the caller falls back to `surface_content_elements` (subsurface- + // aware) for such a window instead. Nothing here actually enforced that: + // without this check, a window whose real content lives in a subsurface + // (confirmed live on the udev/Pixman backend's identical bug: Firefox + // does this) would round and return just its own root surface - often + // blank/background-only - instead of falling back, silently dropping + // the window's actual content rather than merely leaving it unrounded. + if !smithay::wayland::compositor::get_children(surface).is_empty() { + return None; + } smithay::wayland::compositor::with_states(surface, |states| { smithay::backend::renderer::utils::import_surface(renderer, states).ok()?; Some(()) diff --git a/crates/wayland/src/rounded_corners_pixman.rs b/crates/wayland/src/rounded_corners_pixman.rs index 1aaa5bc..1879b9e 100644 --- a/crates/wayland/src/rounded_corners_pixman.rs +++ b/crates/wayland/src/rounded_corners_pixman.rs @@ -49,6 +49,7 @@ use smithay::backend::renderer::utils::{with_renderer_surface_state, RendererSur use smithay::reexports::wayland_server::protocol::wl_shm; 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}; /// Builds a rounded-corner-masked copy of `surface`'s own committed content, @@ -57,6 +58,21 @@ use smithay::wayland::shm::{with_buffer_contents, BufferData}; /// 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())) diff --git a/crates/wayland/src/screencopy.rs b/crates/wayland/src/screencopy.rs index cb632aa..14627e2 100644 --- a/crates/wayland/src/screencopy.rs +++ b/crates/wayland/src/screencopy.rs @@ -19,7 +19,8 @@ use std::time::UNIX_EPOCH; -use smithay::backend::allocator::Fourcc; +use smithay::backend::allocator::dmabuf::{Dmabuf, DmabufMappingMode, DmabufSyncFlags}; +use smithay::backend::allocator::{Buffer as AllocatorBuffer, Fourcc, Modifier}; use smithay::backend::renderer::{ExportMem, Renderer}; use smithay::output::Output; use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; @@ -29,6 +30,7 @@ use smithay::reexports::wayland_server::{ Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, }; use smithay::utils::{Buffer as BufferCoord, Physical, Rectangle, Size}; +use smithay::wayland::dmabuf::get_dmabuf; use smithay::wayland::shm::with_buffer_contents_mut; use wayland_protocols_wlr::screencopy::v1::server::{ zwlr_screencopy_frame_v1::{self, ZwlrScreencopyFrameV1}, @@ -42,6 +44,14 @@ use crate::state::CompState; const BYTES_PER_PIXEL: u32 = 4; const CAPTURE_FOURCC: Fourcc = Fourcc::Xrgb8888; const CAPTURE_SHM_FORMAT: wl_shm::Format = wl_shm::Format::Xrgb8888; +/// `PixmanRenderer::dmabuf_formats()` (`smithay-0.7.0/src/backend/renderer/ +/// pixman/mod.rs`) pairs every format it supports - `Xrgb8888` included -- +/// with `Modifier::Linear` only; it never advertises a tiled/compressed +/// modifier. So any dmabuf a client builds against srdwm's own +/// `zwp_linux_dmabuf_v1` global is guaranteed to be a plain linear buffer, +/// safe to `mmap` and `memcpy` into directly, with no vendor tiling to +/// account for. +const CAPTURE_MODIFIER: Modifier = Modifier::Linear; /// The screencopy manager global. Held by `CompState` purely to keep the /// global alive for the compositor's lifetime. @@ -55,10 +65,20 @@ impl ScreencopyState { where D: GlobalDispatch<ZwlrScreencopyManagerV1, ()> + 'static, { - // Version 3 advertises `linux_dmabuf`/`buffer_done`, which this - // software-readback implementation does not support, so cap at 2 -- - // that still covers `copy_with_damage`, which `wf-recorder` uses. - Self { _global: dh.create_global::<D, ZwlrScreencopyManagerV1, _>(2, ()) } + // Version 3 additionally advertises `linux_dmabuf`/`buffer_done`, + // letting a client request a dmabuf-backed capture instead of shm + // (what screen-sharing consumers - e.g. a WebRTC/PipeWire producer + // sitting behind xdg-desktop-portal - actually want, since it lets + // them hand the frame to a GPU pipeline without an extra copy back + // out of shared memory). This compositor has no GPU rendering path + // at all (`PixmanRenderer` is pure software, see this crate's udev + // backend), but that turns out not to matter here: the CLIENT + // allocates the dmabuf, informed by the format/modifier list this + // compositor's own `zwp_linux_dmabuf_v1` global already advertises + // (see `CAPTURE_MODIFIER`'s doc comment) - this side only ever + // needs to `mmap` the client's buffer and `memcpy` captured pixels + // into it, exactly as it already does for shm (see `copy_region`). + Self { _global: dh.create_global::<D, ZwlrScreencopyManagerV1, _>(3, ()) } } } @@ -147,6 +167,16 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for CompState { region.size.h as u32, region.size.w as u32 * BYTES_PER_PIXEL, ); + // `linux_dmabuf`/`buffer_done` are `since = 3`; a v1/v2 client's + // `frame` object - which inherits the version the client bound the + // manager global at, not this global's advertised maximum - can't + // receive them, so this is gated the same way `damage`/`with_damage` + // already is below (client requests that don't exist below a given + // version can't reach a handler that assumes they do). + if frame.version() >= 3 { + frame.linux_dmabuf(CAPTURE_FOURCC as u32, region.size.w as u32, region.size.h as u32); + frame.buffer_done(); + } } } @@ -178,15 +208,29 @@ impl Dispatch<ZwlrScreencopyFrameV1, FrameData> for CompState { // Validate the buffer really can hold the frame before promising a // capture - a mismatch here would otherwise be a silent short write. + // `get_dmabuf` only succeeds for a buffer imported through + // `zwp_linux_dmabuf_v1` (i.e. one the client built in response to + // this frame's own `linux_dmabuf` event, since=3); anything else -- + // every v1/v2 client, and any v3 client that chose the shm offer + // instead - falls through to the pre-existing shm check below. let expected_stride = data.region.size.w as u32 * BYTES_PER_PIXEL; - let ok = with_buffer_contents_mut(&buffer, |_ptr, len, spec| { - spec.format == CAPTURE_SHM_FORMAT - && spec.width == data.region.size.w - && spec.height == data.region.size.h - && spec.stride as u32 == expected_stride - && len >= (expected_stride * data.region.size.h as u32) as usize - }) - .unwrap_or(false); + let ok = if let Ok(dmabuf) = get_dmabuf(&buffer) { + dmabuf.num_planes() == 1 + && dmabuf.format().code == CAPTURE_FOURCC + && dmabuf.format().modifier == CAPTURE_MODIFIER + && dmabuf.size().w == data.region.size.w + && dmabuf.size().h == data.region.size.h + && dmabuf.strides().next() == Some(expected_stride) + } else { + with_buffer_contents_mut(&buffer, |_ptr, len, spec| { + spec.format == CAPTURE_SHM_FORMAT + && spec.width == data.region.size.w + && spec.height == data.region.size.h + && spec.stride as u32 == expected_stride + && len >= (expected_stride * data.region.size.h as u32) as usize + }) + .unwrap_or(false) + }; if !ok { frame.post_error( zwlr_screencopy_frame_v1::Error::InvalidBuffer, @@ -309,6 +353,10 @@ where return Err(format!("readback produced {} bytes, need {}", pixels.len(), needed)); } + if let Ok(dmabuf) = get_dmabuf(buffer) { + return write_dmabuf(dmabuf, pixels, needed); + } + with_buffer_contents_mut(buffer, |ptr, len, _spec| { if len < needed { return Err(format!("client buffer holds {len} bytes, need {needed}")); @@ -323,3 +371,25 @@ where }) .map_err(|e| format!("client buffer not accessible: {e}"))? } + +/// Writes captured pixels into a client-allocated dmabuf, in place of +/// `with_buffer_contents_mut`'s shm path. The frame's `Copy`/`CopyWithDamage` +/// handler already checked `num_planes() == 1` and `modifier == +/// CAPTURE_MODIFIER` (`Modifier::Linear`) before queuing this capture, so a +/// plain single `mmap` + `memcpy` is correct here - no plane math, no tiling +/// to undo. +fn write_dmabuf(dmabuf: &Dmabuf, pixels: &[u8], needed: usize) -> Result<(), String> { + let mapping = dmabuf.map_plane(0, DmabufMappingMode::WRITE).map_err(|e| format!("map_plane: {e}"))?; + if mapping.length() < needed { + return Err(format!("client dmabuf holds {} bytes, need {needed}", mapping.length())); + } + dmabuf.sync_plane(0, DmabufSyncFlags::START | DmabufSyncFlags::WRITE).map_err(|e| format!("sync_plane(start): {e}"))?; + // SAFETY: `map_plane` guarantees `ptr()` is valid for `length()` bytes + // for as long as `mapping` is alive, and `needed <= length()` was just + // checked. Source and destination are distinct mappings. + unsafe { + std::ptr::copy_nonoverlapping(pixels.as_ptr(), mapping.ptr().cast::<u8>(), needed); + } + dmabuf.sync_plane(0, DmabufSyncFlags::END | DmabufSyncFlags::WRITE).map_err(|e| format!("sync_plane(end): {e}"))?; + Ok(()) +} diff --git a/crates/wayland/src/state/geometry.rs b/crates/wayland/src/state/geometry.rs index c3de7fa..a0d10fa 100644 --- a/crates/wayland/src/state/geometry.rs +++ b/crates/wayland/src/state/geometry.rs @@ -28,7 +28,7 @@ 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)) = self.wm.borrow().window(id).map(|w| (w.geometry, w.decorated)) else { return }; + let Some((target, decorated, maximized, fullscreen)) = self.wm.borrow().window(id).map(|w| (w.geometry, w.decorated, w.maximized, w.fullscreen)) else { return }; if let Some(from) = anim_from { let duration_ms = self.wm.borrow().animation_duration_ms; if from != target && duration_ms > 0 { @@ -93,6 +93,39 @@ impl CompState { state.states.set(xdg_toplevel::State::TiledRight); state.states.set(xdg_toplevel::State::TiledTop); state.states.set(xdg_toplevel::State::TiledBottom); + // Same "no configure from this compositor ever set + // this" gap as the tiled bits above, confirmed the + // same way (grepped the whole crate for `State:: + // Maximized`/`State::Fullscreen` outside foreign- + // toplevel-management, which is a *different* + // protocol read by external tools like a taskbar, + // not the client's own `xdg_toplevel` configure -- + // zero hits there before this). The window was + // resized to the full monitor rect and told it was + // tiled on every side, but never actually told via + // the real protocol mechanism for it that it was + // maximized or fullscreen at all - indistinguishable + // from an ordinary tiled-to-the-edges floating + // window as far as the client could tell. Reported + // live as fullscreen leaving a persistent gap along + // one edge (Firefox keeping some of its own chrome + // logic that specifically keys off genuinely + // *knowing* it's fullscreen, not just being resized + // to fullscreen-sized). `unset` the other explicitly + // when only one applies - `WindowManager:: + // toggle_fullscreen`/`toggle_maximize` are mutually + // exclusive, but nothing here should assume that + // holds forever just because it does today. + if maximized { + state.states.set(xdg_toplevel::State::Maximized); + } else { + state.states.unset(xdg_toplevel::State::Maximized); + } + if fullscreen { + state.states.set(xdg_toplevel::State::Fullscreen); + } else { + state.states.unset(xdg_toplevel::State::Fullscreen); + } }); top.send_configure(); } diff --git a/crates/wayland/src/state/layers.rs b/crates/wayland/src/state/layers.rs index 1a02858..34db3f1 100644 --- a/crates/wayland/src/state/layers.rs +++ b/crates/wayland/src/state/layers.rs @@ -1,6 +1,113 @@ +use smithay::backend::renderer::utils::{with_renderer_surface_state, RendererSurfaceState}; + use super::*; impl CompState { + /// Keeps a layer surface's exclusive-zone reservation honest against + /// whether it currently has anything to show - called from `commit()` + /// before `ensure_layer_initial_configure`, for every surface in the + /// desktop (same cheap non-layer-surface early-out reasoning as that + /// function's own doc comment). + /// + /// The protocol's own text (`wlr-layer-shell-unstable-v1.xml`): + /// "Attaching a null buffer to a layer surface unmaps it." Nothing in + /// smithay's `LayerMap` acts on that by itself - `arrange()` walks + /// every layer in `self.layers` unconditionally, using each one's + /// last-requested `exclusive_zone` regardless of whether it currently + /// has a buffer. `layer_destroyed` (protocols.rs) already handles the + /// *destroyed* case with its own zone_before/`unmap_layer`/zone_after + /// diff; this is the same fix for a client that hides by committing a + /// null buffer while keeping the `zwlr_layer_surface_v1` object alive + /// - cheaper than destroying and recreating it, and exactly what + /// AGS's dock does to hide itself for a fullscreen window. Without + /// this, the dock's last-requested exclusive zone stayed reserved the + /// entire time it was hidden - a real, empty, unexplained band at the + /// screen edge, reported live by an AGS peer session's own measurement + /// across a genuine fullscreen toggle (the dock's zone correctly + /// dropped to 0 on a real *maximize*, ruling that path out). + /// + /// `hidden_layer_surfaces` (see its own doc comment) is what makes the + /// reverse direction work: `unmap_layer` removes the surface from + /// `LayerMap`'s own list, so there is no way to find it again via + /// `layer_for_surface` once that happens - this is the only record + /// of "this surface is mine to re-map" for when a real buffer comes + /// back. + pub(crate) fn sync_layer_visibility(&mut self, surface: &WlSurface) { + let Some(initial_configure_sent) = with_states(surface, |states| { + states.data_map.get::<LayerSurfaceData>().map(|d| d.lock().unwrap().initial_configure_sent) + }) else { + return; + }; + // A surface's very first commit legitimately has no buffer yet -- + // that's the protocol's own required handshake (commit once with + // nothing attached so the compositor can send the *first* + // `configure`, only after which the client is allowed to attach + // real content at all), not a client "hiding" anything. Treating it + // as a hide (the bug this early-return fixes) called `unmap_layer` + // before `ensure_layer_initial_configure` ever ran, which made that + // function's own `layer_for_surface` lookup find nothing and skip + // sending the configure entirely - every layer-shell client + // (a bar, a dock, a wallpaper daemon) left waiting forever for + // permission to draw it was never going to get, each eventually + // giving up and destroying/recreating its surface in a loop. + // Confirmed live: zero "sent initial configure" log lines across an + // entire session, and a repeating ~2-minute create/destroy cycle for + // every `gtk4-layer-shell` surface. Only a surface that has already + // completed its initial handshake can meaningfully "hide" by + // committing a null buffer later - that's the real case this + // function still needs to handle, below. + if !initial_configure_sent { + return; + } + let has_buffer = with_renderer_surface_state(surface, |state: &mut RendererSurfaceState| state.buffer().is_some()).unwrap_or(false); + + // TEMPORARY diagnostic for the "AGS dock reserves its exclusive + // zone but never paints" investigation, relayed from the AGS peer + // session - this function's own `has_buffer`-based hide/show logic + // (see the doc comment above) is the leading suspect: if a client + // ever legitimately commits a null buffer for a reason other than + // an intentional hide (an internal `Gtk.Revealer` transition + // artifact, a resize-in-progress commit), this treats it as a hide, + // `unmap_layer`s it, and requires a *later* has-buffer commit to + // ever come back - which would look exactly like this symptom if + // the client's own state machine doesn't expect the compositor to + // have done that and never re-triggers one. Logs every call for + // every layer surface, not just suspected ones, since which + // surface is actually affected isn't confirmed yet. Remove once + // resolved. + log::warn!("LAYER-VIS-DIAG surface={:?} has_buffer={has_buffer} already_hidden={}", surface.id(), self.hidden_layer_surfaces.contains_key(surface)); + + if has_buffer { + let Some((output, layer)) = self.hidden_layer_surfaces.remove(surface) else { return }; + let mut map = layer_map_for_output(&output); + let zone_before = map.non_exclusive_zone(); + let _ = map.map_layer(&layer); + let zone_after = map.non_exclusive_zone(); + log::warn!("LAYER-VIS-DIAG re-mapped namespace={:?} zone_before={zone_before:?} zone_after={zone_after:?}", layer.namespace()); + if zone_after != zone_before { + self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0)))); + } + return; + } + + if self.hidden_layer_surfaces.contains_key(surface) { + return; + } + for output in self.outputs().cloned().collect::<Vec<_>>() { + let mut map = layer_map_for_output(&output); + let Some(layer) = map.layers().find(|l| l.wl_surface() == surface).cloned() else { continue }; + let zone_before = map.non_exclusive_zone(); + map.unmap_layer(&layer); + let zone_after = map.non_exclusive_zone(); + log::warn!("LAYER-VIS-DIAG unmapped namespace={:?} zone_before={zone_before:?} zone_after={zone_after:?}", layer.namespace()); + if zone_after != zone_before { + self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0)))); + } + drop(map); + self.hidden_layer_surfaces.insert(surface.clone(), (output, layer)); + break; + } + } /// Layer surfaces need a configure sent in direct response to their /// first commit (sending it any earlier violates the protocol - see diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs index 834cf6a..e5b4d6a 100644 --- a/crates/wayland/src/state/mod.rs +++ b/crates/wayland/src/state/mod.rs @@ -40,10 +40,10 @@ use smithay::wayland::dmabuf::DmabufState; use smithay::wayland::shm::ShmState; use smithay::wayland::xdg_activation::XdgActivationState; -use srdwm_core::{Event as CoreEvent, Window as CoreWindow, WindowId, WindowManager, TITLEBAR_HEIGHT}; +use srdwm_core::{Event as CoreEvent, SnapZoneKind, Window as CoreWindow, WindowId, WindowManager, TITLEBAR_HEIGHT}; use crate::lock::SessionLock; -use crate::{decoration, foreign_toplevel, gamma_control, output_management, output_power, screencopy, udev, workspace, xwayland}; +use crate::{appmenu, decoration, foreign_toplevel, gamma_control, output_management, output_power, screencopy, udev, workspace, xwayland}; #[derive(Default)] pub(crate) struct ClientState { @@ -87,6 +87,34 @@ impl OutputEntry { } } +/// Every input `redraw_decoration_buffer` reads to decide what a window's +/// titlebar/border pixels look like - see `CompState::decoration_ +/// signatures`'s own doc comment for why this exists. `title` is the one +/// field worth noting the cost of cloning: short in practice (a window +/// title), and only compared/cloned once per call to this already-more- +/// expensive-than-a-string-clone rasterization function, not once per +/// frame. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct DecorationSignature { + pub(crate) width: u32, + /// The shadow bitmap's own inputs (`height`, `maximized`, `fullscreen`, + /// and the global `shadows_enabled` setting) belong here too, even + /// though nothing above the titlebar/border needs them - one + /// signature covering every input this function reads, not one that + /// only happens to match the titlebar/border's inputs and silently + /// skips a shadow update a real state change needed. + pub(crate) height: u32, + pub(crate) decorated: bool, + pub(crate) focused: bool, + pub(crate) title: String, + pub(crate) border_color: (u8, u8, u8), + pub(crate) border_width: u32, + pub(crate) corner_radius: u32, + pub(crate) maximized: bool, + pub(crate) fullscreen: bool, + pub(crate) shadows_enabled: bool, +} + /// Everything smithay's protocol handlers need `&mut` access to. This is the /// `D` type parameter of `Display<D>` - every `delegate_*!` macro below /// requires the corresponding `*Handler` trait to be implemented on it. @@ -154,6 +182,14 @@ pub(crate) struct CompState { /// Captures requested via `wlr-screencopy` but not yet serviced; drained /// inside the render pass (see `screencopy::service_pending`). pub(crate) screencopy_pending: Vec<screencopy::PendingCapture>, + /// `org_kde_kwin_appmenu_manager` - not `Option`-gated, same reasoning + /// as `_output_management_state` below: exporting a menu D-Bus address + /// straight from a Wayland-native client has nothing GPU/DRM-specific + /// about it, so both backends advertise it. See `appmenu.rs`'s module + /// doc comment for why this exists alongside `xwayland.rs::read_global_ + /// 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, 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 -- @@ -207,6 +243,19 @@ pub(crate) struct CompState { pub(crate) cursor_buffers: crate::cursor::CursorBuffers, /// Last titlebar press, for double-click detection. pub(crate) last_titlebar_click: Option<(WindowId, u32)>, + /// Finger count and accumulated horizontal offset of an in-progress + /// touchpad swipe (`GestureSwipeBegin`..`GestureSwipeUpdate`*.. + /// `GestureSwipeEnd`) - `None` between gestures. Finger count is only + /// ever reported on the `Begin` event, so it has to be carried forward + /// to be checked at `End`. `GestureSwipeUpdateEvent::delta_x` is a + /// per-update offset, not a running total (see the smithay struct's own + /// doc comment: "relative to the previous event"), so the offset half + /// has to sum across every update itself; only the total at `End` + /// decides whether the swipe crossed the switch-workspace threshold. + /// Never forwarded to a client - see `input::handle_gesture_swipe_end`'s + /// doc comment for why 3+-finger swipe is claimed entirely by the + /// compositor. + pub(crate) gesture_swipe: Option<(u32, f64)>, /// The right-click titlebar window menu, if one is currently open -- /// see `context_menu.rs`. `None` almost always; a click anywhere while /// `Some` resolves (selects a row) or dismisses it, never falls @@ -217,6 +266,16 @@ pub(crate) struct CompState { /// `decorations`/`border_top_decorations` already use, not rebuilt /// per frame. pub(crate) context_menu_buffer: Option<MemoryRenderBuffer>, + /// The Snap-Layouts flyout, if one is currently open - see + /// `snap_flyout.rs`. Same lifecycle as `context_menu` above (mutually + /// exclusive in practice, since both close on any click elsewhere), just + /// a separate field rather than an enum of the two: they render + /// differently, are triggered by different clicks, and nothing needs to + /// treat them uniformly. + pub(crate) snap_flyout: Option<crate::snap_flyout::SnapFlyout>, + /// Rasterised pixels for the currently-open `snap_flyout`, same + /// build-once-on-open pattern as `context_menu_buffer`. + pub(crate) snap_flyout_buffer: Option<MemoryRenderBuffer>, pub(crate) wm: Rc<RefCell<WindowManager>>, pub(crate) surface_to_id: HashMap<WlSurface, WindowId>, pub(crate) id_to_window: HashMap<WindowId, DWindow>, @@ -229,6 +288,21 @@ pub(crate) struct CompState { /// after the client destroys it too and Rust never reuses the id while /// any handle (including this one) still exists. pub(crate) dead_layer_surfaces: HashSet<WlSurface>, + /// Layer surfaces this compositor has unmapped itself in response to a + /// null-buffer commit - `wlr-layer-shell-unstable-v1.xml`'s own text: + /// "Attaching a null buffer to a layer surface unmaps it", but nothing + /// in smithay's `LayerMap` does that automatically (`arrange()` walks + /// every layer in its list unconditionally, buffer or not; only an + /// explicit `unmap_layer` call removes one). `layer_destroyed` already + /// did this for the surface-destroyed case; `sync_layer_visibility` + /// (state/layers.rs) does it for the hide-without-destroying one -- + /// AGS's dock, hiding for a fullscreen window, being the live case + /// that surfaced this. Stores the output it was unmapped from plus the + /// `LayerSurface` handle itself: `unmap_layer` removes it from + /// `LayerMap`'s own list, so `layer_for_surface` can never find it + /// again on its own - this is the only way `sync_layer_visibility` + /// can re-map it once the client commits real content again. + pub(crate) hidden_layer_surfaces: HashMap<WlSurface, (smithay::output::Output, smithay::desktop::LayerSurface)>, pub(crate) decorations: HashMap<WindowId, MemoryRenderBuffer>, /// The top border strip's rounded-corner bitmap, cached the same way /// and at the same trigger points as `decorations` (built in @@ -240,6 +314,24 @@ pub(crate) struct CompState { /// [`Self::border_top_decorations`]'s mirror for the bottom strip's own /// two corners - same cache, same trigger points, same reasoning. pub(crate) border_bottom_decorations: HashMap<WindowId, MemoryRenderBuffer>, + /// What `redraw_decoration_buffer` last actually rendered for a window + /// - every input its own rasterization reads (width, `decorated`, + /// focus, title text, border colour/width) - so a call that would + /// rebuild the exact same pixels can skip doing so instead. + /// + /// Exists because `main.rs`'s `sync()` calls `Platform::redraw_ + /// decoration` - which always reaches this - for *every visible + /// window*, on *every* tick that has anything at all marked dirty, not + /// only the window whose state actually changed: a resize drag alone + /// fires this for every other open window too, once per pointer-motion + /// event, each one re-rendering title text and re-rasterizing border + /// strips into a freshly allocated buffer for no visible difference. + /// The `decorations`/`border_*_decorations` doc comments already + /// establish "only rebuild at real trigger points" as the intended + /// contract; this closes the gap between that intent and `sync()`'s + /// 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>, /// 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 @@ -272,13 +364,18 @@ pub(crate) struct CompState { 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 it was built from - see - /// `elements::rounded_content_buffer`, which owns rebuilding this. + /// 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, MemoryRenderBuffer)>, + pub(crate) rounded_content_buffers: HashMap<WindowId, (u64, u32, 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 @@ -325,6 +422,13 @@ pub(crate) struct CompState { /// module docs for why this needs its own connection rather than going /// through `X11Wm`. `None` until XWayland is ready, same as `xwm`. pub(crate) ewmh: Option<xwayland::EwmhState>, + /// `com.canonical.AppMenu.Registrar` - the classic Qt/`appmenu-qt5` + /// global-menu source, see `srdwm_platform::appmenu_registrar`'s module + /// doc comment for why it lives in the shared platform crate rather + /// than here. `None` until XWayland is ready, same as `ewmh`/`xwm` -- + /// constructed alongside `ewmh` in `xwayland.rs::spawn`'s `XWaylandEvent + /// ::Ready` handler, since a raw X11 window id is meaningless without it. + pub(crate) appmenu_registrar: Option<srdwm_platform::AppmenuRegistrarState>, /// `ext_idle_notify_v1` - lets a client (a lock daemon, a bar's idle /// indicator) ask to be told after N seconds of no real input. Both /// this and `_idle_inhibit_manager_state` below use smithay's own diff --git a/crates/wayland/src/udev/capture.rs b/crates/wayland/src/udev/capture.rs new file mode 100644 index 0000000..0f87d27 --- /dev/null +++ b/crates/wayland/src/udev/capture.rs @@ -0,0 +1,129 @@ +//! Off-screen render of a workspace that isn't necessarily the one +//! currently on screen - `srd capture workspace <id> <path>`, drained +//! from `WindowManager::drain_capture_requests` on every poll. See +//! `srdwm_core::CaptureRequest`'s own doc comment for why this exists at +//! all: `wlr-screencopy` (`crates/wayland/src/screencopy.rs`, and `grim`) +//! can only ever see what an output is actually presenting, and a +//! workspace switcher's thumbnail needs exactly the opposite - a +//! 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. + +use super::*; +use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::element::surface::{render_elements_from_surface_tree, WaylandSurfaceRenderElement}; +use smithay::backend::renderer::element::Kind; +use smithay::backend::renderer::{Bind, ExportMem, Offscreen}; +use smithay::utils::{Buffer as BufferCoord, Transform}; + +impl CompState { + /// Services every capture request queued since the last poll. Takes + /// the `Vec` by value for the same reason `screencopy::service_pending` + /// does: the renderer this needs lives behind `self.udev`'s own + /// mutable borrow, so the request list has to be lifted out of + /// `self.wm` before that borrow starts. + pub(crate) fn service_capture_requests(&mut self, requests: Vec<srdwm_core::CaptureRequest>) { + for req in requests { + if let Err(e) = self.capture_workspace(&req) { + log::warn!("capture: workspace {} -> {}: {e}", req.workspace, req.path); + } + } + } + + fn capture_workspace(&mut self, req: &srdwm_core::CaptureRequest) -> Result<(), String> { + // The monitor a freshly-placed window on this workspace would land + // on: workspaces aren't per-monitor in this compositor (a single + // `current_workspace` is shared by every screen - see + // `WindowManager`'s own field doc comment), so there's no single + // "this workspace's monitor" to ask for; the primary one is the + // same reasonable default `arrange_workspace` itself falls back to. + let (origin, native): ((i32, i32), (u32, u32)) = { + let wm = self.wm.borrow(); + let monitor = wm.monitors().iter().find(|m| m.primary).or_else(|| wm.monitors().first()).ok_or("no monitor to capture from")?; + ((monitor.full_geometry.x, monitor.full_geometry.y), (monitor.full_geometry.width, monitor.full_geometry.height)) + }; + if native.0 == 0 || native.1 == 0 { + return Err("monitor has zero size".to_string()); + } + + 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()) }; + 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 }; + let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue }; + let loc = (geom.x - origin.0, geom.y - origin.1); + elements.extend(render_elements_from_surface_tree(&mut udev.renderer, &surface, loc, 1.0, 1.0, Kind::Unspecified)); + } + + let (nw, nh) = (native.0 as i32, native.1 as i32); + let mut target = udev.renderer.create_buffer(Fourcc::Xrgb8888, (nw, nh).into()).map_err(|e| format!("create_buffer: {e}"))?; + let mut framebuffer = udev.renderer.bind(&mut target).map_err(|e| format!("bind: {e}"))?; + let mut tracker = OutputDamageTracker::new((nw, nh), 1.0, Transform::Normal); + tracker + .render_output(&mut udev.renderer, &mut framebuffer, 0, &elements, [0.0, 0.0, 0.0, 1.0]) + .map_err(|e| format!("render_output: {e:?}"))?; + + let region: Rectangle<i32, BufferCoord> = Rectangle::new((0, 0).into(), (nw, nh).into()); + let mapping = udev.renderer.copy_framebuffer(&framebuffer, region, Fourcc::Xrgb8888).map_err(|e| format!("copy_framebuffer: {e}"))?; + let pixels = udev.renderer.map_texture(&mapping).map_err(|e| format!("map_texture: {e}"))?; + + write_ppm(pixels, native, req.size, &req.path) + } +} + +/// `pixels` is `Xrgb8888` - 4 bytes per pixel, little-endian, so byte +/// order in memory is B, G, R, X. PPM (`P6`) wants tightly-packed R, G, B +/// with no pad byte, hence the reorder rather than a straight `memcpy`. +/// Downscales with plain nearest-neighbor sampling when `target` is +/// smaller than `native` - a thumbnail has no need for anything more +/// expensive, and this avoids pulling in an image-scaling crate for one +/// call site. +fn write_ppm(pixels: &[u8], native: (u32, u32), target: Option<(u32, u32)>, path: &str) -> Result<(), String> { + let (nw, nh) = native; + let (tw, th) = target.unwrap_or(native); + if tw == 0 || th == 0 { + return Err("requested capture size is zero".to_string()); + } + let src_stride = nw as usize * 4; + let needed = src_stride * nh as usize; + if pixels.len() < needed { + return Err(format!("readback produced {} bytes, need {needed}", pixels.len())); + } + + let mut rgb = Vec::with_capacity(tw as usize * th as usize * 3); + for ty in 0..th { + // `.min(nh - 1)`/`.min(nw - 1)`: guards the last row/column of a + // downscale from ever reading one pixel past the source when an + // integer ratio rounds up, not a real expectation of overflow. + let sy = (ty as u64 * nh as u64 / th as u64).min(nh as u64 - 1) as usize; + for tx in 0..tw { + let sx = (tx as u64 * nw as u64 / tw as u64).min(nw as u64 - 1) as usize; + let i = sy * src_stride + sx * 4; + rgb.push(pixels[i + 2]); // R + rgb.push(pixels[i + 1]); // G + rgb.push(pixels[i]); // B + } + } + + let mut out = format!("P6\n{tw} {th}\n255\n").into_bytes(); + out.extend_from_slice(&rgb); + // Written to a `.tmp` sibling and renamed into place: a reader (AGS's + // wsPreview poller) racing a partial write is exactly the kind of + // flicker/corruption a debounced, event-driven cache is supposed to + // avoid - `rename` within the same directory is atomic, a plain + // `write` never is. + let tmp = format!("{path}.tmp"); + std::fs::write(&tmp, &out).map_err(|e| format!("write {tmp}: {e}"))?; + std::fs::rename(&tmp, path).map_err(|e| format!("rename to {path}: {e}")) +} diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs index 2d9f9ff..501e3fc 100644 --- a/crates/wayland/src/udev/mod.rs +++ b/crates/wayland/src/udev/mod.rs @@ -33,8 +33,9 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use smithay::backend::input::{ - Axis, ButtonState as BackendButtonState, Event as InputEventTrait, InputEvent, PointerAxisEvent, - PointerButtonEvent, PointerMotionEvent, + 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; @@ -47,7 +48,10 @@ use smithay::backend::udev::{self, UdevBackend, UdevEvent}; use smithay::desktop::{layer_map_for_output, PopupManager, Space}; use smithay::backend::input::AxisSource; use smithay::wayland::shell::wlr_layer::Layer; -use smithay::input::pointer::AxisFrame; +use smithay::input::pointer::{ + AxisFrame, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, + GesturePinchUpdateEvent, +}; use smithay::input::SeatState; use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel}; use smithay::reexports::calloop::generic::{FdWrapper, Generic}; @@ -63,7 +67,7 @@ use smithay::reexports::pixman::{FormatCode, Image}; use smithay::reexports::rustix; use smithay::reexports::wayland_server::backend::GlobalId; use smithay::reexports::wayland_server::{Client, Display, DisplayHandle, ListeningSocket}; -use smithay::utils::{Logical, Physical, Point, Rectangle, Scale, Size, Transform}; +use smithay::utils::{Logical, Physical, Point, Rectangle, Scale, Size, Transform, SERIAL_COUNTER}; use smithay::wayland::compositor::CompositorState; use smithay::wayland::dmabuf::DmabufState; use smithay::wayland::selection::data_device::DataDeviceState; @@ -79,7 +83,10 @@ use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformRe use crate::decoration; use crate::err; -use crate::input::{handle_keyboard_key_event, handle_pointer_button, handle_pointer_position}; +use crate::input::{ + handle_gesture_swipe_begin, handle_gesture_swipe_end, handle_gesture_swipe_update, handle_keyboard_key_event, + handle_pointer_button, handle_pointer_position, +}; use crate::state::{ClientState, CompState}; /// A DRM device node, opened through the session (not a raw `File::open`) @@ -158,6 +165,14 @@ pub(crate) struct UdevState { /// Pointer position in the *global* space, so it can cross between /// monitors; clamped to the union of all head rectangles. pub(crate) pointer_pos: Point<f64, Logical>, + /// A clone of the same `LibSeatSession` `platform.rs` opened the DRM + /// device with (`LibSeatSession` is cheaply `Clone` - see its own + /// derive - all clones share the same underlying seat connection). + /// Kept here, reachable from `input.rs`'s keyboard handler, purely so + /// `Ctrl+Alt+F<n>` can call `change_vt` on it - nothing else in this + /// backend needed the session handle after startup, so it was never + /// retained anywhere before this. + pub(crate) session: LibSeatSession, } impl UdevState { @@ -226,6 +241,7 @@ impl UdevHead { } } +mod capture; mod drm; mod outputs; mod platform; diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index f562da2..8676a5a 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -113,6 +113,7 @@ impl UdevPlatform { heads, active: true, pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(), + session: session.clone(), }; let state = CompState { @@ -141,6 +142,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), _foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&display_handle), foreign_toplevel_managers: Vec::new(), foreign_toplevel_handles: HashMap::new(), @@ -170,15 +172,20 @@ impl UdevPlatform { cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), cursor_buffers: crate::cursor::make_buffers(), last_titlebar_click: None, + gesture_swipe: None, context_menu: None, context_menu_buffer: None, + snap_flyout: None, + snap_flyout_buffer: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), dead_layer_surfaces: HashSet::new(), + hidden_layer_surfaces: HashMap::new(), decorations: HashMap::new(), border_top_decorations: HashMap::new(), border_bottom_decorations: HashMap::new(), + decoration_signatures: HashMap::new(), shadow_buffers: HashMap::new(), rounded_corners_program: None, content_epoch: HashMap::new(), @@ -196,6 +203,7 @@ impl UdevPlatform { xwayland_windows: HashMap::new(), xwayland_pending: Vec::new(), ewmh: None, + appmenu_registrar: None, }; let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?; @@ -262,9 +270,92 @@ impl Platform for UdevPlatform { 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() { if ipc.poll(&self.state.wm) { self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged); + // `ipc.rs`'s `handle_request` (`"focus"`, `"toggle + // visibility"`, ...) only ever touches core's `WindowManager` + // - it has no handle to `state.space`, which is what + // actually renders on top *and* what `space.element_under` + // hit-tests against (see `input::focus_window`'s own doc + // comment, which fixed every *other* focus path this same + // way). Left alone, a dock/AGS "focus" click over IPC moved + // core's idea of focus while the window kept rendering, and + // hit-testing, underneath whatever was already topmost -- + // reproduced live: `srd dispatch focus` on a covered Firefox + // window raised it in the taskbar/keyboard sense but a + // click at its own visible location still landed on the + // window still actually on top. Re-syncing here rather than + // in `ipc.rs` itself since core is platform-agnostic and + // cannot see `state.space`; cheap and safe to call + // unconditionally on any IPC mutation, not just ones that + // are definitely focus changes - raising an already-topmost + // element is a no-op reinsertion. + let focused = self.state.wm.borrow().focused_id(); + if let Some(id) = focused { + crate::input::focus_window(&mut self.state, id); + } + } + } + // Starts srdwm's own lock UI if `srd dispatch lock` queued a + // request since the last poll - see `WindowManager::request_lock`'s + // own doc comment for why this crosses the core/backend boundary + // as a drained request rather than a direct call. A no-op if + // already locked (native or external), same guard `begin_native_ + // lock` applies itself. + if self.state.wm.borrow_mut().drain_lock_request() { + self.state.begin_native_lock(); + } + // Same drained-request pattern as the lock check just above, for + // `srd capture workspace` - see `WindowManager::request_capture_ + // workspace`'s own doc comment for why this needs the backend at + // all rather than being answerable from core state. + let capture_requests = self.state.wm.borrow_mut().drain_capture_requests(); + if !capture_requests.is_empty() { + self.state.service_capture_requests(capture_requests); + } + // Checks whether a background PAM authentication spawned by a + // native lock's own `Return` handling finished since the last + // poll - see `native_lock.rs`'s module doc comment for why this + // runs on a background thread rather than blocking here. + self.state.poll_native_lock_auth(); + // Applies any `srd set_output_position` IPC requests queued since + // the last poll - see `WindowManager::request_output_position`'s + // own doc comment for why this indirection exists at all (core has + // no real output handle to move itself). `id` is this head's index + // into `udev.heads` *as of the platform's last `monitors()` query* + // (see that function's own construction of `Monitor::new(i as u32, + // ...)`) - stale if a hotplug reordered heads in between, same + // trade-off `wlr-output-management-v1`'s own `apply_or_test` + // guards against with a serial check. Not guarded the same way + // here: this is a first pass at the primitive a display-settings + // panel needs to build real monitor mirroring on top of, not yet + // hardened against a hotplug racing an in-flight request - worth + // adding if that turns out to matter in practice. + let output_requests = self.state.wm.borrow_mut().drain_output_position_requests(); + if !output_requests.is_empty() { + let mut any_applied = false; + for (id, x, y) in output_requests { + let Some(output) = self.state.udev.as_ref().and_then(|u| u.heads.get(id as usize)).map(|h| h.output.clone()) else { + log::warn!("udev: set_output_position: no head at index {id}"); + continue; + }; + crate::output_management::apply_output_position(&mut self.state, &output, (x, y).into()); + any_applied = true; + } + if any_applied { + crate::output_management::broadcast_dirty_outputs(&mut self.state); + // Core's own `Monitor` list is a passive mirror of whatever + // the backend last reported (see `monitors()` above) -- + // without re-triggering a query, `Window.geometry`/ + // placement would keep using the pre-move rect until some + // unrelated event happened to refresh it. `MonitorAdded`'s + // payload is discarded unread on this path (`main.rs` + // re-queries the full list rather than trusting it), same + // as every other "just go recompute" use of this event + // elsewhere in this codebase. + 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(); @@ -313,6 +404,7 @@ impl Platform for UdevPlatform { // 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 }) @@ -396,4 +488,23 @@ impl Platform for UdevPlatform { fn ungrab_keyboard(&mut self) -> PlatformResult<()> { Ok(()) } + + fn keyboard_layout(&mut self) -> PlatformResult<String> { + let Some(keyboard) = self.state.seat.get_keyboard() else { return Ok(String::new()) }; + Ok(keyboard.with_xkb_state(&mut self.state, |ctx| { + let xkb = ctx.xkb().lock().unwrap(); + let layout = xkb.active_layout(); + xkb.layout_name(layout).to_string() + })) + } + + fn cycle_keyboard_layout(&mut self) -> PlatformResult<String> { + let Some(keyboard) = self.state.seat.get_keyboard() else { return Ok(String::new()) }; + Ok(keyboard.with_xkb_state(&mut self.state, |mut ctx| { + ctx.cycle_next_layout(); + let xkb = ctx.xkb().lock().unwrap(); + let layout = xkb.active_layout(); + xkb.layout_name(layout).to_string() + })) + } } diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs index caf0e0e..9642603 100644 --- a/crates/wayland/src/udev/render.rs +++ b/crates/wayland/src/udev/render.rs @@ -17,6 +17,15 @@ impl CompState { // Same reason: the cursor needs the renderer that borrow owns. let cursor_status = self.cursor_status.clone(); let cursor_buffers = self.cursor_buffers.clone(); + // Same reason again: a native lock's capture step (below) needs + // this, and `self.wm` can't be borrowed once `self.udev` is. + let lock_blur_radius = self.wm.borrow().lock.blur_radius; + // Captured-and-blurred backgrounds collected during the per-head + // loop below, applied via `self.capture_output` only after it + // ends - `self.udev`'s mutable borrow is held for the whole loop + // body, and that method needs the whole of `self`, not just the + // one field the loop already has. + let mut new_captures: Vec<(String, smithay::backend::renderer::element::memory::MemoryRenderBuffer)> = Vec::new(); // Border geometry is in global space, independent of which head // renders it, so it's gathered once here rather than per head. @@ -77,6 +86,12 @@ impl CompState { let mut presented: Vec<(Output, 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 + // `native_lock::native_lock_render_elements`'s own doc comment + // for why (cheap `MemoryRenderBuffer` clones, not a pixel copy). + let native_bg = self.native_lock_background(&output.name()).cloned(); + let native_ui = self.native_lock_ui().map(|(buf, size)| (buf.clone(), size)); + let native_needs_capture = self.native_lock_needs_capture(&output.name()); // Content/decoration elements are built per head: both need the // renderer, and geometry is translated into head-local space. @@ -112,6 +127,15 @@ impl CompState { Err(e) => log::warn!("udev: failed to import context menu buffer: {e}"), } } + // The Snap-Layouts flyout, if open - same "topmost but + // never hides the cursor" placement as the context menu. + if let (Some(flyout), Some(buffer)) = (self.snap_flyout.as_ref(), self.snap_flyout_buffer.as_ref()) { + let pos = ((flyout.pos.0 - origin.x) as f64, (flyout.pos.1 - 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 snap flyout buffer: {e}"), + } + } // Popups next: always above every window's own content, // matching this codebase's long-standing behavior from // before content moved into this same `custom_elements` @@ -316,7 +340,7 @@ impl CompState { // 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, decoration::CORNER_RADIUS as f32, corners) + 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) { @@ -327,7 +351,9 @@ impl CompState { } match rounded_elem { Some(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), - None => custom_elements.extend(crate::elements::surface_content_elements(&mut udev.renderer, &surface, pos, w.opacity)), + None => { + custom_elements.extend(crate::elements::surface_content_elements(&mut udev.renderer, &surface, pos, w.opacity)); + } } } } @@ -343,11 +369,23 @@ impl CompState { |layer| matches!(layer, Layer::Background | Layer::Bottom), )); } - let lock_elements = if locked { + // Three genuinely different element types (external `LockSurface` + // content, srdwm's own memory-backed background+UI, or the + // normal desktop's `custom_elements`), so each is built and + // passed to its own `render_output` call below rather than + // forced into one shared, unified element list. + let is_native = self.lock.native.is_some(); + let lock_elements = if locked && !is_native { crate::lock::lock_render_elements(lock_surface.as_ref(), &mut udev.renderer) } else { Vec::new() }; + let native_elements = if locked && is_native { + let size = udev.heads[index].size; + crate::native_lock::native_lock_render_elements(native_bg.as_ref(), native_ui.as_ref().map(|(b, s)| (b, *s)), size, &mut udev.renderer) + } else { + Vec::new() + }; let head = &mut udev.heads[index]; let mut framebuffer = match udev.renderer.bind(&mut head.buffers[back].image) { @@ -358,9 +396,18 @@ impl CompState { } }; - // Locked heads draw the lock surface over opaque black and - // nothing else; unlocked heads draw the normal scene. - let result = if locked { + // Locked heads draw either srdwm's own native lock UI (over + // opaque black - the background element covers the visible + // area, but the clear colour is still what shows through if a + // capture failed or hasn't happened for this output yet) or an + // external locker's surface the same way, and nothing else; + // unlocked heads draw the normal scene. + let result = if locked && is_native { + head.damage_tracker + .render_output(&mut udev.renderer, &mut framebuffer, 0, &native_elements, [0.0, 0.0, 0.0, 1.0]) + .map(|r| (r.damage.is_some(), Vec::new())) + .map_err(|e| e.to_string()) + } else if locked { head.damage_tracker .render_output(&mut udev.renderer, &mut framebuffer, 0, &lock_elements, [0.0, 0.0, 0.0, 1.0]) .map(|r| (r.damage.is_some(), Vec::new())) @@ -401,6 +448,22 @@ impl CompState { let (mine, rest): (Vec<_>, Vec<_>) = captures.into_iter().partition(|c| c.output == output); captures = rest; crate::screencopy::service_pending(mine, &mut udev.renderer, &framebuffer); + + // A native lock is waiting on this output's background -- + // this same freshly-rendered framebuffer (the ordinary + // desktop scene, not a lock scene: `locked` is still + // `false` here because `begin_native_lock` deliberately + // doesn't flip it until every output has one, see that + // function's own doc comment) is exactly "what's on + // screen right now" for this output. + if native_needs_capture { + let name = output.name(); + let size = head.size; + match crate::native_lock::capture_and_blur(&mut udev.renderer, &framebuffer, size, lock_blur_radius) { + Ok(blurred) => new_captures.push((name, blurred)), + Err(e) => log::warn!("native lock: capture failed for output {name}: {e}"), + } + } } drop(framebuffer); @@ -441,6 +504,15 @@ impl CompState { } } + // Applies every background captured during the loop above, now + // that `self.udev`'s borrow has ended and `self` (specifically + // `self.lock`) can be borrowed as a whole again - see + // `capture_output`'s own doc comment for what happens once every + // output has one (the lock actually engages). + for (name, blurred) in new_captures { + self.capture_output(&name, blurred); + } + // Frame callbacks + lock confirmation, once the `udev` borrow is done. for (output, damage_rects) in presented { if locked { diff --git a/crates/wayland/src/udev/session.rs b/crates/wayland/src/udev/session.rs index fa40b0e..4dd6db0 100644 --- a/crates/wayland/src/udev/session.rs +++ b/crates/wayland/src/udev/session.rs @@ -214,6 +214,41 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB pointer.axis(state, frame); pointer.frame(state); } + // 3+-finger swipe - claimed entirely for workspace switching, never + // reaches a client. See `handle_gesture_swipe_end`'s doc comment. + InputEvent::GestureSwipeBegin { event } => handle_gesture_swipe_begin(state, &event), + InputEvent::GestureSwipeUpdate { event } => handle_gesture_swipe_update(state, &event), + InputEvent::GestureSwipeEnd { event } => handle_gesture_swipe_end(state, &event), + // Pinch/hold: no WM-level meaning, forwarded to the focused client + // as-is (`wp_pointer_gestures`) - pinch-to-zoom in an image viewer + // or PDF reader, the one real use either has. Same reasoning as the + // `PointerAxis` forwarding above: nothing here should be silently + // dropped just because this WM has no use for it itself. + InputEvent::GesturePinchBegin { event } => { + let Some(pointer) = state.seat.get_pointer() else { return }; + let fingers = event.fingers(); + pointer.gesture_pinch_begin(state, &GesturePinchBeginEvent { serial: SERIAL_COUNTER.next_serial(), time: event.time_msec(), fingers }); + } + InputEvent::GesturePinchUpdate { event } => { + let Some(pointer) = state.seat.get_pointer() else { return }; + let (delta, scale, rotation) = (event.delta(), event.scale(), event.rotation()); + pointer.gesture_pinch_update(state, &GesturePinchUpdateEvent { time: event.time_msec(), delta, scale, rotation }); + } + InputEvent::GesturePinchEnd { event } => { + let Some(pointer) = state.seat.get_pointer() else { return }; + let cancelled = event.cancelled(); + pointer.gesture_pinch_end(state, &GesturePinchEndEvent { serial: SERIAL_COUNTER.next_serial(), time: event.time_msec(), cancelled }); + } + InputEvent::GestureHoldBegin { event } => { + let Some(pointer) = state.seat.get_pointer() else { return }; + let fingers = event.fingers(); + pointer.gesture_hold_begin(state, &GestureHoldBeginEvent { serial: SERIAL_COUNTER.next_serial(), time: event.time_msec(), fingers }); + } + InputEvent::GestureHoldEnd { event } => { + let Some(pointer) = state.seat.get_pointer() else { return }; + let cancelled = event.cancelled(); + pointer.gesture_hold_end(state, &GestureHoldEndEvent { serial: SERIAL_COUNTER.next_serial(), time: event.time_msec(), cancelled }); + } _ => {} } } diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs index d771d1f..0f3a6d3 100644 --- a/crates/wayland/src/winit/connect.rs +++ b/crates/wayland/src/winit/connect.rs @@ -112,6 +112,7 @@ impl WaylandPlatform { session_lock_state: SessionLockManagerState::new::<CompState, _>(&dh, |_| true), _screencopy_state: screencopy::ScreencopyState::new::<CompState>(&dh), screencopy_pending: Vec::new(), + _appmenu_state: crate::appmenu::AppmenuManagerState::new::<CompState>(&dh), _foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&dh), foreign_toplevel_managers: Vec::new(), foreign_toplevel_handles: HashMap::new(), @@ -141,15 +142,20 @@ impl WaylandPlatform { cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), cursor_buffers: crate::cursor::make_buffers(), last_titlebar_click: None, + gesture_swipe: None, context_menu: None, context_menu_buffer: None, + snap_flyout: None, + snap_flyout_buffer: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), dead_layer_surfaces: HashSet::new(), + hidden_layer_surfaces: HashMap::new(), decorations: HashMap::new(), border_top_decorations: HashMap::new(), border_bottom_decorations: HashMap::new(), + decoration_signatures: HashMap::new(), shadow_buffers: HashMap::new(), rounded_corners_program, content_epoch: HashMap::new(), @@ -167,6 +173,7 @@ impl WaylandPlatform { xwayland_windows: HashMap::new(), xwayland_pending: Vec::new(), ewmh: None, + appmenu_registrar: None, }; let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?; diff --git a/crates/wayland/src/winit/platform.rs b/crates/wayland/src/winit/platform.rs index 23387b7..96adde8 100644 --- a/crates/wayland/src/winit/platform.rs +++ b/crates/wayland/src/winit/platform.rs @@ -43,8 +43,26 @@ impl Platform for WaylandPlatform { if let Some(ipc) = self.ipc.as_mut() { if ipc.poll(&self.wm) { self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged); + // Same re-sync as `udev/platform.rs`'s matching block - see + // its own comment. `handle_request` only ever touches core's + // `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. + let focused = self.wm.borrow().focused_id(); + if let Some(id) = focused { + crate::input::focus_window(&mut self.state, id); + } } } + // Same lock-request draining as `udev/platform.rs`'s matching + // block - see its own comment. Exercised here too (not just on + // the real udev backend) specifically so a native lock can be + // tested against this nested dev session without ever touching + // the live tty1 one. + if self.wm.borrow_mut().drain_lock_request() { + self.state.begin_native_lock(); + } + self.state.poll_native_lock_auth(); let wait = TARGET_FRAME_TIME.saturating_sub(self.last_frame.elapsed()); let _ = self.idle_event_loop.dispatch(Some(wait), &mut self.state); self.last_frame = Instant::now(); @@ -69,6 +87,7 @@ impl Platform for WaylandPlatform { // "usable, shrunk rect" `toggle_maximize` targets. let full = self.backend.window_size(); m.full_geometry = srdwm_core::Rect::new(0, 0, full.w as u32, full.h as u32); + m.maximize_geometry = crate::input::maximize_geometry_for(&self.output, m.full_geometry); m.primary = true; m }]) @@ -145,4 +164,23 @@ impl Platform for WaylandPlatform { fn ungrab_keyboard(&mut self) -> PlatformResult<()> { Ok(()) } + + fn keyboard_layout(&mut self) -> PlatformResult<String> { + let Some(keyboard) = self.state.seat.get_keyboard() else { return Ok(String::new()) }; + Ok(keyboard.with_xkb_state(&mut self.state, |ctx| { + let xkb = ctx.xkb().lock().unwrap(); + let layout = xkb.active_layout(); + xkb.layout_name(layout).to_string() + })) + } + + fn cycle_keyboard_layout(&mut self) -> PlatformResult<String> { + let Some(keyboard) = self.state.seat.get_keyboard() else { return Ok(String::new()) }; + Ok(keyboard.with_xkb_state(&mut self.state, |mut ctx| { + ctx.cycle_next_layout(); + let xkb = ctx.xkb().lock().unwrap(); + let layout = xkb.active_layout(); + xkb.layout_name(layout).to_string() + })) + } } diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs index 3ab3354..735ff34 100644 --- a/crates/wayland/src/winit/render.rs +++ b/crates/wayland/src/winit/render.rs @@ -22,8 +22,22 @@ impl WaylandPlatform { let age = self.backend.buffer_age().unwrap_or(0); let (renderer, mut framebuffer) = self.backend.bind().map_err(err)?; - // Locked: the lock surface over an opaque black clear, and nothing - // else - no windows, no decorations, no layer surfaces. + // Locked: srdwm's own native lock UI, or an external locker's + // surface, over an opaque black clear - nothing else, no windows, + // no decorations, no layer surfaces, either way. + if self.state.lock.locked && self.state.lock.native.is_some() { + let name = self.output.name(); + let bg = self.state.native_lock_background(&name).cloned(); + let ui = self.state.native_lock_ui().map(|(buf, s)| (buf.clone(), s)); + let elements = crate::native_lock::native_lock_render_elements(bg.as_ref(), ui.as_ref().map(|(b, s)| (b, *s)), (size.w, size.h), renderer); + self.damage_tracker + .render_output(renderer, &mut framebuffer, age, &elements, [0.0, 0.0, 0.0, 1.0]) + .map_err(err)?; + drop(framebuffer); + self.backend.submit(None).map_err(err)?; + screencopy::fail_pending(std::mem::take(&mut self.state.screencopy_pending)); + return Ok(()); + } if self.state.lock.locked { let lock_surface = self.state.lock_surface_for(&self.output).cloned(); let elements = lock_render_elements(lock_surface.as_ref(), renderer); @@ -58,6 +72,14 @@ impl WaylandPlatform { Err(e) => log::warn!("failed to import context menu buffer: {e}"), } } + // The Snap-Layouts flyout, if open - same topmost placement. + if let (Some(flyout), Some(buffer)) = (self.state.snap_flyout.as_ref(), self.state.snap_flyout_buffer.as_ref()) { + let pos = (flyout.pos.0 as f64, flyout.pos.1 as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, 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 snap flyout buffer: {e}"), + } + } // Content now renders here too, one window at a time, not through // `render_output`'s own `spaces` argument - see this function's // own call to `damage_tracker.render_output` further down for why, @@ -228,7 +250,7 @@ impl WaylandPlatform { let pos = (geom.x, geom.y + band); let rounded = rounded_corners_enabled.then_some(self.state.rounded_corners_program.as_ref()).flatten().and_then(|program| { let corners = if w.decorated { crate::rounded_corners::RoundedCorners::BOTTOM_ONLY } else { crate::rounded_corners::RoundedCorners::ALL }; - crate::rounded_corners::rounded_content_element(renderer, program, &surface, pos, w.opacity, crate::decoration::CORNER_RADIUS as f32, corners) + crate::rounded_corners::rounded_content_element(renderer, program, &surface, pos, w.opacity, w.corner_radius as f32, corners) }); match rounded { Some(elem) => custom_elements.push(crate::rounded_corners::WinitElement::Rounded(elem)), @@ -257,6 +279,22 @@ impl WaylandPlatform { .map_err(err)?; let damage_rects: Vec<Rectangle<i32, Physical>> = result.damage.cloned().unwrap_or_default(); let has_damage = !damage_rects.is_empty(); + // A native lock is waiting on this output's background - same + // capture hook as `udev/render.rs`'s matching point, exercised + // here too so a native lock can be tested against this nested dev + // session without ever touching the live tty1 one. `self.state. + // lock.locked` is still `false` at this point (`begin_native_lock` + // doesn't flip it until every output has a background - see its + // own doc comment), so the frame just rendered into `framebuffer` + // is the ordinary desktop scene, not a lock scene. + if self.state.native_lock_needs_capture(&self.output.name()) { + let name = self.output.name(); + let blur_radius = self.state.wm.borrow().lock.blur_radius; + match crate::native_lock::capture_and_blur(renderer, &framebuffer, size.into(), blur_radius) { + Ok(blurred) => self.state.capture_output(&name, blurred), + Err(e) => log::warn!("native lock: capture failed for output {name}: {e}"), + } + } drop(framebuffer); // Both the buffer swap and the frame-callback notification are // conditional on real damage now - this used to run diff --git a/crates/wayland/src/xwayland.rs b/crates/wayland/src/xwayland.rs index 67ca44a..d8c56a3 100644 --- a/crates/wayland/src/xwayland.rs +++ b/crates/wayland/src/xwayland.rs @@ -29,7 +29,7 @@ use smithay::xwayland::xwm::{Reorder, ResizeEdge as X11ResizeEdge, WmWindowPrope use smithay::xwayland::{X11Surface, X11Wm, XWayland, XWaylandEvent, XwmHandler}; use smithay::{delegate_xwayland_shell, desktop::Window as DWindow}; -use srdwm_core::{Event as CoreEvent, ResizeEdge, Window as CoreWindow, TITLEBAR_HEIGHT}; +use srdwm_core::{classify_menu_source, Event as CoreEvent, ResizeEdge, Window as CoreWindow, TITLEBAR_HEIGHT}; use crate::state::CompState; @@ -79,6 +79,7 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm data.xwm = Some(wm); fix_wm_name(display_number); data.ewmh = EwmhState::connect(display_number); + data.appmenu_registrar = Some(srdwm_platform::AppmenuRegistrarState::new()); } Err(e) => log::error!("failed to start X11 window manager for XWayland: {e}"), } @@ -190,6 +191,18 @@ pub(crate) struct EwmhState { gtk_menubar_object_path: Option<u32>, gtk_app_menu_object_path: Option<u32>, unity_object_path: Option<u32>, + /// KWin's own global-menu property pair - what `libdbusmenu-qt`'s KDE + /// integration sets, and (unlike every other atom here) not something + /// `classify_menu_source` needs to disambiguate at all: unlike the GTK/ + /// Unity atoms, which can legitimately overlap on one window (the + /// `appmenu-gtk-module` shim case), these two together are already a + /// complete, unambiguous `com.canonical.dbusmenu` address on their own + /// - checked first in `read_global_menu`, before the GTK/Unity atoms, + /// so a Qt app running under a KDE Plasma session (which sets these, + /// never any `_GTK_*` atom) isn't rejected by `bus_name`'s hard + /// requirement on `_GTK_UNIQUE_BUS_NAME` before ever reaching them. + kde_appmenu_service_name: Option<u32>, + kde_appmenu_object_path: Option<u32>, } impl EwmhState { @@ -220,6 +233,8 @@ impl EwmhState { let gtk_menubar_object_path = intern("_GTK_MENUBAR_OBJECT_PATH"); let gtk_app_menu_object_path = intern("_GTK_APP_MENU_OBJECT_PATH"); let unity_object_path = intern("_UNITY_OBJECT_PATH"); + let kde_appmenu_service_name = intern("_KDE_NET_WM_APPMENU_SERVICE_NAME"); + let kde_appmenu_object_path = intern("_KDE_NET_WM_APPMENU_OBJECT_PATH"); let state = Self { conn, root, @@ -232,6 +247,8 @@ impl EwmhState { gtk_menubar_object_path, gtk_app_menu_object_path, unity_object_path, + kde_appmenu_service_name, + kde_appmenu_object_path, }; // `_NET_CLIENT_LIST`/`_STACKING` are properties on the X root window, // which XWayland recreates fresh on every launch - but nothing @@ -284,6 +301,15 @@ impl EwmhState { String::from_utf8(reply.value).ok().filter(|s| !s.is_empty()) }; + // Checked before anything GTK-atom-related: these two, together, + // are already a complete address on their own - no classification + // needed - and a Qt app running under a KDE Plasma session never + // sets `_GTK_UNIQUE_BUS_NAME` at all, so falling through to that + // atom's hard requirement below would reject it outright. + if let (Some(bus_name), Some(menu_path)) = (read_string(self.kde_appmenu_service_name), read_string(self.kde_appmenu_object_path)) { + return Some(srdwm_core::GlobalMenu { bus_name, menu_path: Some(menu_path), app_path: None, window_path: None, source: srdwm_core::MenuSource::DbusMenu }); + } + let bus_name = read_string(self.gtk_unique_bus_name)?; let app_path = read_string(self.gtk_application_object_path); let window_path = read_string(self.gtk_window_object_path); @@ -344,22 +370,6 @@ impl EwmhState { } } -/// The decision `read_global_menu` needs, pulled out as a pure function so -/// it's unit-testable without a real X connection (matching this codebase's -/// existing "no smithay/X11 dependency" convention for logic that doesn't -/// actually need one, e.g. `decoration.rs`) - see that method's own doc -/// comment for the full reasoning behind why `is_real_gtk_application` -/// overrides "a GTK path exists" rather than the reverse. -fn classify_menu_source(gtk_menu_path: Option<String>, is_real_gtk_application: bool, unity_path: Option<String>) -> (Option<String>, srdwm_core::MenuSource) { - match gtk_menu_path { - Some(path) if !is_real_gtk_application => (Some(path), srdwm_core::MenuSource::Unity), - Some(path) => (Some(path), srdwm_core::MenuSource::Gtk), - None => match unity_path { - Some(path) => (Some(path), srdwm_core::MenuSource::Unity), - None => (None, srdwm_core::MenuSource::Gtk), - }, - } -} impl CompState { /// Call on every focus change (from `set_keyboard_focus`, the single @@ -389,6 +399,38 @@ impl CompState { } } + /// 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`. + /// + /// `RegisterWindow`/`UnregisterWindow`'s `window_id` is a raw X11 XID, + /// with nothing here already mapping XID back to `WindowId` (`xwm`'s + /// own maps go the other way) - a linear scan over `id_to_window` is + /// fine for it: this only runs when a registrar event actually arrives, + /// not every tick, and the number of open windows is never large enough + /// for a scan to matter. + pub(crate) fn apply_registrar_events(&mut self) { + let Some(registrar) = &self.appmenu_registrar else { return }; + let events = registrar.drain_events(); + if events.is_empty() { + return; + } + for event in events { + let (window_id, menu) = match event { + srdwm_platform::RegistrarEvent::Registered { window_id, bus_name, menu_path } => ( + window_id, + Some(srdwm_core::GlobalMenu { bus_name, menu_path: Some(menu_path), app_path: None, window_path: None, source: srdwm_core::MenuSource::DbusMenu }), + ), + srdwm_platform::RegistrarEvent::Unregistered { window_id } => (window_id, None), + }; + let id = self.id_to_window.iter().find(|(_, w)| w.x11_surface().map(|x| x.window_id()) == Some(window_id)).map(|(id, _)| *id); + let Some(id) = id else { continue }; + if let Some(w) = self.wm.borrow_mut().window_mut(id) { + w.global_menu = menu; + } + } + } + /// Call whenever the set of mapped windows changes (X11 window map, /// unmap, or destroy - see the `XwmHandler` methods below). pub(crate) fn update_net_client_list(&self) { @@ -457,39 +499,6 @@ mod tests { assert_eq!(shell_single_quote("/usr/bin/Xwayland"), "'/usr/bin/Xwayland'"); assert_eq!(shell_single_quote("/it's/here"), r"'/it'\''s/here'"); } - - #[test] - fn appmenu_gtk_module_shim_is_classified_as_unity_not_gtk() { - // The exact live case that motivated this: a GTK menubar path - // present, but no application/window object path - confirmed by - // an AGS peer session reading the actual exported menu content off - // the bus and finding `unity.`-prefixed actions despite the GTK - // atom being what resolved the path. - let (path, source) = classify_menu_source(Some("/org/appmenu/gtk/window/0".to_string()), false, None); - assert_eq!(path.as_deref(), Some("/org/appmenu/gtk/window/0"), "the path itself is still correct - only the label was wrong"); - assert_eq!(source, srdwm_core::MenuSource::Unity); - } - - #[test] - fn real_gtk_application_export_is_still_classified_as_gtk() { - let (path, source) = classify_menu_source(Some("/org/gtk/menus/window/1".to_string()), true, None); - assert_eq!(path.as_deref(), Some("/org/gtk/menus/window/1")); - assert_eq!(source, srdwm_core::MenuSource::Gtk); - } - - #[test] - fn plain_unity_object_path_with_no_gtk_atom_is_unaffected() { - let (path, source) = classify_menu_source(None, false, Some("/com/canonical/menu/1".to_string())); - assert_eq!(path.as_deref(), Some("/com/canonical/menu/1")); - assert_eq!(source, srdwm_core::MenuSource::Unity); - } - - #[test] - fn neither_path_present_is_none() { - let (path, source) = classify_menu_source(None, false, None); - assert_eq!(path, None); - assert_eq!(source, srdwm_core::MenuSource::Gtk); - } } fn to_core_resize_edge(edge: X11ResizeEdge) -> ResizeEdge { @@ -579,6 +588,9 @@ impl CompState { if self.context_menu.as_ref().is_some_and(|m| m.window == id) { self.close_context_menu(); } + if self.snap_flyout.as_ref().is_some_and(|f| f.window == id) { + self.close_snap_flyout(); + } self.wm.borrow_mut().remove_window(id); self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); crate::foreign_toplevel::window_closed(self, id); |