srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/macos/src/lib.rs8
-rw-r--r--crates/wayland/Cargo.toml18
-rw-r--r--crates/wayland/src/cursor.rs242
-rw-r--r--crates/wayland/src/decoration.rs203
-rw-r--r--crates/wayland/src/elements.rs56
-rw-r--r--crates/wayland/src/input.rs339
-rw-r--r--crates/wayland/src/lib.rs4
-rw-r--r--crates/wayland/src/output_management.rs2
-rw-r--r--crates/wayland/src/protocols.rs64
-rw-r--r--crates/wayland/src/rounded_corners.rs12
-rw-r--r--crates/wayland/src/rounded_corners_pixman.rs16
-rw-r--r--crates/wayland/src/screencopy.rs96
-rw-r--r--crates/wayland/src/state/geometry.rs35
-rw-r--r--crates/wayland/src/state/layers.rs107
-rw-r--r--crates/wayland/src/state/mod.rs114
-rw-r--r--crates/wayland/src/udev/capture.rs129
-rw-r--r--crates/wayland/src/udev/mod.rs26
-rw-r--r--crates/wayland/src/udev/platform.rs111
-rw-r--r--crates/wayland/src/udev/render.rs84
-rw-r--r--crates/wayland/src/udev/session.rs35
-rw-r--r--crates/wayland/src/winit/connect.rs7
-rw-r--r--crates/wayland/src/winit/platform.rs38
-rw-r--r--crates/wayland/src/winit/render.rs44
-rw-r--r--crates/wayland/src/xwayland.rs112
-rw-r--r--crates/windows/src/lib.rs8
25 files changed, 1642 insertions, 268 deletions
diff --git a/crates/macos/src/lib.rs b/crates/macos/src/lib.rs
index ee61d53..672fbfa 100644
--- a/crates/macos/src/lib.rs
+++ b/crates/macos/src/lib.rs
@@ -143,4 +143,12 @@ impl Platform for MacOsPlatform {
fn ungrab_keyboard(&mut self) -> Result<()> {
Err(PlatformError::Unsupported("ungrab_keyboard"))
}
+
+ fn keyboard_layout(&mut self) -> Result<String> {
+ Err(PlatformError::Unsupported("keyboard_layout"))
+ }
+
+ fn cycle_keyboard_layout(&mut self) -> Result<String> {
+ Err(PlatformError::Unsupported("cycle_keyboard_layout"))
+ }
}
diff --git a/crates/wayland/Cargo.toml b/crates/wayland/Cargo.toml
index 5f06617..1ddddc6 100644
--- a/crates/wayland/Cargo.toml
+++ b/crates/wayland/Cargo.toml
@@ -11,10 +11,20 @@ srdwm-platform.workspace = true
log.workspace = true
thiserror.workspace = true
fontdue = "0.9"
+# `udev/capture.rs`: builds a `pixman::Image` off-screen render target
+# directly (`PixmanRenderer::create_buffer`/`Offscreen` hand one back, but
+# nothing constructs one standalone). Pinned to the same version smithay
+# itself depends on (see Cargo.lock), so both see one set of types.
+pixman = "0.2.1"
# smithay 0.7 ships no wlr-screencopy helper, so `src/screencopy.rs`
# implements that protocol directly against these server bindings. Pinned to
# the same version smithay itself depends on, so both see one set of types.
wayland-protocols-wlr = { version = "0.3.12", features = ["server"] }
+# `appmenu.rs`: `org_kde_kwin_appmenu` (global menu for Wayland-native
+# clients - the equivalent of `xwayland.rs`'s X11-property reading, for
+# windows that never go through XWayland at all). Same sibling-crate/no
+# smithay-helper situation as the wlr protocols above, pinned the same way.
+wayland-protocols-plasma = { version = "0.3.12", features = ["server"] }
# `ext-workspace-v1` (`src/workspace.rs`): staging-tree protocol, not
# wlr-specific - lives in the plain `wayland-protocols` crate instead, and
# (like the wlr protocol above) has no smithay helper to delegate to.
@@ -34,6 +44,14 @@ memmap2 = "0.9"
wayland-scanner = "0.31.10"
wayland-backend = { version = "0.3.15", features = ["server_system"] }
xcursor = "0.3.11"
+# `native_lock.rs`: converts a pressed key's `Keysym` (from `KeysymHandle::
+# modified_sym()`) into the printable character it represents, for the
+# lock screen's own password field - smithay has no helper for this
+# itself, only the raw `Keysym`. Pinned to the same version smithay itself
+# depends on (with the same `wayland` feature), same reasoning as every
+# other "pinned to smithay's own version" dependency above - both need to
+# see one identical `Keysym` type, not two incompatible ones.
+xkbcommon = { version = "0.8", features = ["wayland"] }
[dependencies.smithay]
version = "0.7"
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);
diff --git a/crates/windows/src/lib.rs b/crates/windows/src/lib.rs
index b876eb0..bc785f4 100644
--- a/crates/windows/src/lib.rs
+++ b/crates/windows/src/lib.rs
@@ -247,4 +247,12 @@ impl Platform for WindowsPlatform {
fn ungrab_keyboard(&mut self) -> Result<()> {
Err(PlatformError::Unsupported("ungrab_keyboard"))
}
+
+ fn keyboard_layout(&mut self) -> Result<String> {
+ Err(PlatformError::Unsupported("keyboard_layout"))
+ }
+
+ fn cycle_keyboard_layout(&mut self) -> Result<String> {
+ Err(PlatformError::Unsupported("cycle_keyboard_layout"))
+ }
}