From 1c175642d073689ca11b9252411ea5f8446007d0 Mon Sep 17 00:00:00 2001
From: srdusr <99972264+srdusr@users.noreply.github.com>
Date: Thu, 30 May 2024 16:10:00 +0200
Subject: Fix decoration drift during animated transitions; checkpoint
IPC/global-menu/output-management work
Border/titlebar decoration was built from Window.geometry (the animation's
final target) in both wayland backends' render loops, while sync_geometry
already draws a window's actual content at window_anims' interpolated rect
during any maximize/fullscreen/open-slide tween. Border and content read two
different rectangles for the whole transition, so the border visibly
detached from the window it was outlining - reported as "borders aren't
flush." Both udev.rs and winit.rs now read the same animated rect for
titlebar placement, border-strip placement, and the occlusion test against
later windows in stacking order. Verified: cargo build --workspace, cargo
clippy (0 new warnings), cargo test -p srdwm-core (111/111).
Also checkpoints substantial protocol/IPC work from prior sessions that had
accumulated uncommitted: gtk-shell1 support (gtk_shell.rs, the vendored
gtk-shell.xml, xwayland.rs's X11-side mirror) backing the global app menu;
zwlr_foreign_toplevel_manager_v1 (foreign_toplevel.rs) broadcasting distinct
maximized/minimized/fullscreen/activated state per window; output_management
(ext-output-management + layer-shell exclusive-zone reservation tracking);
workspace.rs and context_menu.rs; a Unix-socket IPC crate (platform/src/
ipc.rs) and a `srd` control-CLI crate (crates/ctl); xkb_config.rs; and a
theme module (core/src/theme.rs). A peer session working the AGS shell
concurrently verified several of these live against a running srdwm: the
global menu rendering a real app's File/Edit menu over gtk-shell1, and
foreign-toplevel correctly reporting maximized and fullscreen as independent,
non-simultaneous states with the geometry each implies (maximize stops at a
reserved top bar and past a dock; fullscreen reaches the true monitor edge).
---
crates/wayland/Cargo.toml | 18 +
crates/wayland/protocols/gtk-shell.xml | 116 +++++
crates/wayland/src/context_menu.rs | 133 +++++
crates/wayland/src/cursor.rs | 473 ++++++++++++++++-
crates/wayland/src/decoration.rs | 437 +++++++++++++++-
crates/wayland/src/elements.rs | 229 ++++++++-
crates/wayland/src/foreign_toplevel.rs | 379 ++++++++++++++
crates/wayland/src/gamma_control.rs | 90 ++++
crates/wayland/src/gtk_shell.rs | 116 +++++
crates/wayland/src/gtk_shell_protocol.rs | 29 ++
crates/wayland/src/input.rs | 299 ++++++++++-
crates/wayland/src/lib.rs | 9 +
crates/wayland/src/lock.rs | 13 +
crates/wayland/src/output_management.rs | 559 ++++++++++++++++++++
crates/wayland/src/output_power.rs | 99 ++++
crates/wayland/src/protocols.rs | 532 +++++++++++++++++++-
crates/wayland/src/screencopy.rs | 35 +-
crates/wayland/src/state.rs | 840 +++++++++++++++++++++++++++++--
crates/wayland/src/udev.rs | 640 +++++++++++++++++++++--
crates/wayland/src/winit.rs | 419 ++++++++++++++-
crates/wayland/src/workspace.rs | 251 +++++++++
crates/wayland/src/xkb_config.rs | 102 ++++
crates/wayland/src/xwayland.rs | 380 +++++++++++++-
23 files changed, 6016 insertions(+), 182 deletions(-)
create mode 100644 crates/wayland/protocols/gtk-shell.xml
create mode 100644 crates/wayland/src/context_menu.rs
create mode 100644 crates/wayland/src/foreign_toplevel.rs
create mode 100644 crates/wayland/src/gamma_control.rs
create mode 100644 crates/wayland/src/gtk_shell.rs
create mode 100644 crates/wayland/src/gtk_shell_protocol.rs
create mode 100644 crates/wayland/src/output_management.rs
create mode 100644 crates/wayland/src/output_power.rs
create mode 100644 crates/wayland/src/workspace.rs
create mode 100644 crates/wayland/src/xkb_config.rs
(limited to 'crates/wayland')
diff --git a/crates/wayland/Cargo.toml b/crates/wayland/Cargo.toml
index fb86dd8..96d0e50 100644
--- a/crates/wayland/Cargo.toml
+++ b/crates/wayland/Cargo.toml
@@ -15,6 +15,24 @@ fontdue = "0.9"
# 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"] }
+# `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.
+wayland-protocols = { version = "0.32.13", features = ["server", "staging"] }
+serde.workspace = true
+serde_json.workspace = true
+# `gamma_control.rs`: `zwlr_gamma_control_v1.set_gamma` hands over a raw
+# gamma table as a memory-mapped fd, not a value on the wire - already a
+# transitive dependency (smithay's own shm handling uses it), just not
+# previously used directly by this crate.
+memmap2 = "0.9"
+# `gtk_shell_protocol.rs`: `gtk_shell1`/`gtk_surface1` is a GTK-private
+# protocol with no published `wayland-protocols-*` crate (unlike the wlr/
+# staging ones above) - generated at compile time from a vendored copy of
+# GTK's own XML instead. Pinned to the version `wayland-server` above
+# actually resolves to, so both see the same wire types.
+wayland-scanner = "0.31.10"
+wayland-backend = { version = "0.3.15", features = ["server_system"] }
[dependencies.smithay]
version = "0.7"
diff --git a/crates/wayland/protocols/gtk-shell.xml b/crates/wayland/protocols/gtk-shell.xml
new file mode 100644
index 0000000..f514478
--- /dev/null
+++ b/crates/wayland/protocols/gtk-shell.xml
@@ -0,0 +1,116 @@
+
+
+
+
+ gtk_shell is a protocol extension providing additional features for
+ clients implementing it.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/wayland/src/context_menu.rs b/crates/wayland/src/context_menu.rs
new file mode 100644
index 0000000..f11e41b
--- /dev/null
+++ b/crates/wayland/src/context_menu.rs
@@ -0,0 +1,133 @@
+//! Right-click titlebar window menu - the one titlebar interaction
+//! virtually every desktop WM has always offered that srdwm never did.
+//! Right-click on a titlebar previously did nothing at all (the only
+//! right-button behaviour anywhere was the SUPER+right-drag resize
+//! gesture, which needs the modifier held); this gives plain right-click
+//! a real, discoverable action.
+//!
+//! Deliberately minimal: four fixed actions, no submenus, no live hover
+//! highlight (a nice-to-have that would need the render buffer rebuilt on
+//! every pointer-motion event over the menu - not worth the extra
+//! per-frame cost for a first pass). See `decoration::render_context_menu`
+//! for the actual pixels.
+
+use srdwm_core::{WindowId, WindowManager, TITLEBAR_HEIGHT};
+
+#[derive(Clone, Copy)]
+pub(crate) enum MenuAction {
+ Minimize,
+ ToggleMaximize,
+ ToggleAlwaysOnTop,
+ Close,
+}
+
+pub(crate) struct ContextMenu {
+ pub(crate) window: WindowId,
+ /// Top-left corner, in global (output-independent) space - same frame
+ /// `Window.geometry` and every other `custom_elements` position uses.
+ pub(crate) pos: (i32, i32),
+ pub(crate) width: u32,
+ pub(crate) row_height: u32,
+ pub(crate) items: Vec<(&'static str, MenuAction)>,
+}
+
+const MENU_WIDTH: u32 = 170;
+
+impl ContextMenu {
+ /// Builds the menu for `window`, opening with its top-left corner at
+ /// `pos` (wherever the right-click landed). Labels reflect the
+ /// window's *current* state - "Maximize" flips to "Restore", "Always
+ /// on Top" gets a checkmark prefix once pinned - same convention
+ /// every native window menu uses, rather than a static label that
+ /// silently means the opposite of what it says half the time.
+ pub(crate) fn open(wm: &WindowManager, window: WindowId, pos: (i32, i32)) -> Option {
+ let w = wm.window(window)?;
+ let maximize_label = if w.maximized { "Restore" } else { "Maximize" };
+ let pin_label = if w.always_on_top { "\u{2713} Always on Top" } else { "Always on Top" };
+ let items = vec![
+ ("Minimize", MenuAction::Minimize),
+ (maximize_label, MenuAction::ToggleMaximize),
+ (pin_label, MenuAction::ToggleAlwaysOnTop),
+ ("Close", MenuAction::Close),
+ ];
+ Some(Self { window, pos, width: MENU_WIDTH, row_height: TITLEBAR_HEIGHT, items })
+ }
+
+ pub(crate) fn height(&self) -> i32 {
+ self.row_height as i32 * self.items.len() as i32
+ }
+
+ /// Which row (if any) global-space point `(x, y)` falls on.
+ pub(crate) fn row_at(&self, x: i32, y: i32) -> Option {
+ if x < self.pos.0 || x >= self.pos.0 + self.width as i32 {
+ return None;
+ }
+ let rel_y = y - self.pos.1;
+ if rel_y < 0 || rel_y >= self.height() {
+ return None;
+ }
+ Some((rel_y / self.row_height as i32) as usize)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use srdwm_core::Window;
+
+ fn wm_with_window() -> (WindowManager, WindowId) {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![srdwm_core::Monitor::new(0, "primary", srdwm_core::Rect::new(0, 0, 1920, 1080))]);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+ (wm, id)
+ }
+
+ #[test]
+ fn open_labels_maximize_action_by_current_state() {
+ let (mut wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.items[1].0, "Maximize");
+
+ wm.toggle_maximize(id);
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.items[1].0, "Restore");
+ }
+
+ #[test]
+ fn open_marks_pinned_state_on_the_always_on_top_row() {
+ let (mut wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.items[2].0, "Always on Top");
+
+ wm.toggle_always_on_top(id);
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert!(menu.items[2].0.starts_with('\u{2713}'), "pinned state must be visible on the label itself");
+ }
+
+ #[test]
+ fn open_returns_none_for_a_window_that_no_longer_exists() {
+ let (wm, id) = wm_with_window();
+ assert!(ContextMenu::open(&wm, id + 999, (0, 0)).is_none());
+ }
+
+ #[test]
+ fn row_at_maps_a_point_to_the_right_row() {
+ let (wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.row_at(150, 100), Some(0), "top of the first row");
+ assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32 - 1), Some(0), "bottom of the first row");
+ assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32), Some(1), "top of the second row");
+ assert_eq!(menu.row_at(150, 100 + menu.height() - 1), Some(3), "last row, last pixel");
+ }
+
+ #[test]
+ fn row_at_is_none_outside_the_menus_bounds() {
+ let (wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.row_at(99, 110), None, "just left of the menu");
+ assert_eq!(menu.row_at(100 + MENU_WIDTH as i32, 110), None, "just right of the menu");
+ assert_eq!(menu.row_at(150, 99), None, "just above the menu");
+ assert_eq!(menu.row_at(150, 100 + menu.height()), None, "just below the menu");
+ }
+}
diff --git a/crates/wayland/src/cursor.rs b/crates/wayland/src/cursor.rs
index b489c03..d9bc3be 100644
--- a/crates/wayland/src/cursor.rs
+++ b/crates/wayland/src/cursor.rs
@@ -17,9 +17,10 @@
//! we have no art for.
//!
//! `CursorImageStatus::Hidden` is honoured, so a client that hides the
-//! pointer still gets its way. Named shapes (`CursorIcon::Text` etc.) fall
-//! back to the arrow rather than being drawn as the requested shape - most
-//! toolkits set a surface rather than a name, so this is rarely visible.
+//! 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) render as that shape; anything else
+//! falls back to the arrow.
//!
//! The built-in arrow is deliberate rather than loading an XCursor theme:
//! theme loading pulls in a dependency, needs a theme to actually be
@@ -41,6 +42,17 @@ pub(crate) const CURSOR_SIZE: i32 = 24;
/// Encoded as a small bitmap rather than drawn with geometry so the shape is
/// obvious and reviewable: `.` transparent, `#` black outline, `*` white
/// fill. 24 rows of 24 columns.
+///
+/// The tail (below the triangular head, past the horizontal shelf at row
+/// 15) used to fork into two separate legs of visibly different widths --
+/// the left one tapering down to a point like the rest of the shape, the
+/// right one a constant-width block that never tapered at all, ending in
+/// an abrupt flat stop. It rendered fine at a glance in a screenshot but
+/// reads as lopsided/broken up close, exactly as reported live ("one side
+/// is bigger than the other"). Replaced with a single triangular foot,
+/// straight left edge continuing the head's, right edge tapering linearly
+/// inward row by row down to a point - the same shape language the head
+/// itself already uses, just mirrored.
const ARROW: [&str; CURSOR_SIZE as usize] = [
"#.......................",
"##......................",
@@ -57,13 +69,13 @@ const ARROW: [&str; CURSOR_SIZE as usize] = [
"#***********#...........",
"#************#..........",
"#*************#.........",
- "#******####### .........",
- "#***#**#................",
- "#**#.#**#...............",
- "#*#..#**#...............",
- "##....#**#..............",
- "#.....#**#..............",
- ".......###..............",
+ "#******#######..........",
+ "#**********#............",
+ "#********#..............",
+ "#******#................",
+ "#****#..................",
+ "#**#....................",
+ "#.......................",
"........................",
"........................",
];
@@ -90,9 +102,291 @@ pub(crate) fn arrow_bitmap() -> Vec {
buf[i + 3] = a;
}
}
+ add_white_halo(&mut buf);
buf
}
+/// Adds a 1px opaque-white ring around every opaque pixel, on whichever
+/// neighbouring pixels are still fully transparent.
+///
+/// Without this, the black outline drawn by the code above becomes
+/// invisible over a dark background: confirmed live from a screenshot of
+/// this exact arrow over a black terminal, where the outline had merged
+/// completely into the background, leaving only a stark, edgeless white
+/// silhouette. The resize/text shapes (drawn as plain opaque black lines,
+/// no fill - see `set_px`) have the same problem more severely: solid
+/// black on a dark window is close to invisible outright. A halo keeps
+/// every shape readable against both light and dark content underneath it,
+/// the same trick real cursor themes use - it is the faint white fringe
+/// visible around an ordinary system arrow cursor.
+///
+/// Two-pass by construction: halo positions are collected against the
+/// buffer's original opacity before any of them are written, so the ring
+/// stays exactly 1px thick instead of dilating outward on itself.
+fn add_white_halo(buf: &mut [u8]) {
+ let is_opaque = |buf: &[u8], x: i32, y: i32| -> bool {
+ if x < 0 || y < 0 || x >= CURSOR_SIZE || y >= CURSOR_SIZE {
+ return false;
+ }
+ buf[((y * CURSOR_SIZE + x) * 4 + 3) as usize] != 0
+ };
+ let mut halo = Vec::new();
+ for y in 0..CURSOR_SIZE {
+ for x in 0..CURSOR_SIZE {
+ if is_opaque(buf, x, y) {
+ continue;
+ }
+ let touches_shape = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1), (x - 1, y - 1), (x + 1, y - 1), (x - 1, y + 1), (x + 1, y + 1)]
+ .into_iter()
+ .any(|(nx, ny)| is_opaque(buf, nx, ny));
+ if touches_shape {
+ halo.push((x, y));
+ }
+ }
+ }
+ for (x, y) in halo {
+ let i = ((y * CURSOR_SIZE + x) * 4) as usize;
+ buf[i] = 0xff;
+ buf[i + 1] = 0xff;
+ buf[i + 2] = 0xff;
+ buf[i + 3] = 0xff;
+ }
+}
+
+
+/// A small set of built-in cursor bitmaps beyond the default arrow, for the
+/// named shapes a client requests most often: text entry, the four resize
+/// directions, crosshair, move, and the pointing-hand hyperlink-hover
+/// shape. Everything else (grab, wait, help, ...) still falls back to the
+/// arrow - one arrow beats zero effort spent on a dozen rarely-seen icons,
+/// but "I-beam over a text field", "double arrow at a window edge", and
+/// "hand over a link" are common and immediately noticeable when wrong,
+/// which is what made the cursor "always look the same regardless of
+/// what's under it" worth fixing at all.
+///
+/// Built once at startup (`make_buffers`), same as the arrow.
+#[derive(Clone)]
+pub(crate) struct CursorBuffers {
+ pub(crate) arrow: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) text: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) ns_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) ew_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) nesw_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) nwse_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) crosshair: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) move_icon: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ pub(crate) pointer: smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+}
+
+/// Sets one pixel to opaque white-on-black-outline isn't needed here (these
+/// shapes are drawn solid black, unlike the arrow's outline+fill) --
+/// straight opaque black, since these are thin enough that an outline
+/// would just eat the whole shape.
+fn set_px(buf: &mut [u8], x: i32, y: i32) {
+ if x < 0 || y < 0 || x >= CURSOR_SIZE || y >= CURSOR_SIZE {
+ return;
+ }
+ let i = ((y * CURSOR_SIZE + x) * 4) as usize;
+ buf[i] = 0x00;
+ buf[i + 1] = 0x00;
+ buf[i + 2] = 0x00;
+ buf[i + 3] = 0xff;
+}
+
+/// Bresenham line, thickened by `width` (drawn as `width` parallel lines
+/// offset perpendicular to travel) since a single-pixel line is nearly
+/// invisible at this size.
+fn draw_line(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, width: i32) {
+ let (dx, dy) = (x1 - x0, y1 - y0);
+ let len = ((dx * dx + dy * dy) as f32).sqrt().max(1.0);
+ // Perpendicular unit direction, scaled for the offsets below.
+ let (px, py) = (-(dy as f32) / len, (dx as f32) / len);
+ for w in 0..width {
+ let offset = w - width / 2;
+ let ox = (px * offset as f32).round() as i32;
+ let oy = (py * offset as f32).round() as i32;
+ draw_thin_line(buf, x0 + ox, y0 + oy, x1 + ox, y1 + oy);
+ }
+}
+
+fn draw_thin_line(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32) {
+ let (mut x0, mut y0) = (x0, y0);
+ let dx = (x1 - x0).abs();
+ let dy = -(y1 - y0).abs();
+ let sx = if x0 < x1 { 1 } else { -1 };
+ let sy = if y0 < y1 { 1 } else { -1 };
+ let mut err = dx + dy;
+ loop {
+ set_px(buf, x0, y0);
+ if x0 == x1 && y0 == y1 {
+ break;
+ }
+ let e2 = 2 * err;
+ if e2 >= dy {
+ err += dy;
+ x0 += sx;
+ }
+ if e2 <= dx {
+ err += dx;
+ y0 += sy;
+ }
+ }
+}
+
+/// I-beam: a vertical stem with top/bottom serifs, centered in the bitmap
+/// (unlike the arrow, whose hotspot is its tip at (0,0) - an I-beam's
+/// hotspot is its center, where the text caret actually is).
+fn text_bitmap() -> Vec {
+ let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize];
+ let mid = CURSOR_SIZE / 2;
+ draw_line(&mut buf, mid, 3, mid, CURSOR_SIZE - 4, 2);
+ draw_line(&mut buf, mid - 4, 3, mid + 4, 3, 2);
+ draw_line(&mut buf, mid - 4, CURSOR_SIZE - 4, mid + 4, CURSOR_SIZE - 4, 2);
+ add_white_halo(&mut buf);
+ buf
+}
+
+/// Double-headed arrow along one axis (horizontal if `horizontal`,
+/// vertical otherwise), hotspot at center - the standard edge/side resize
+/// cursor shape.
+fn straight_resize_bitmap(horizontal: bool) -> Vec {
+ let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize];
+ let mid = CURSOR_SIZE / 2;
+ let (lo, hi) = (3, CURSOR_SIZE - 4);
+ if horizontal {
+ draw_line(&mut buf, lo, mid, hi, mid, 2);
+ draw_line(&mut buf, lo, mid, lo + 5, mid - 5, 2);
+ draw_line(&mut buf, lo, mid, lo + 5, mid + 5, 2);
+ draw_line(&mut buf, hi, mid, hi - 5, mid - 5, 2);
+ draw_line(&mut buf, hi, mid, hi - 5, mid + 5, 2);
+ } else {
+ draw_line(&mut buf, mid, lo, mid, hi, 2);
+ draw_line(&mut buf, mid, lo, mid - 5, lo + 5, 2);
+ draw_line(&mut buf, mid, lo, mid + 5, lo + 5, 2);
+ draw_line(&mut buf, mid, hi, mid - 5, hi - 5, 2);
+ draw_line(&mut buf, mid, hi, mid + 5, hi - 5, 2);
+ }
+ add_white_halo(&mut buf);
+ buf
+}
+
+/// Double-headed arrow along a diagonal: NW-SE if `nwse`, NE-SW otherwise.
+/// Hotspot at center, same as the straight resize shapes.
+fn diagonal_resize_bitmap(nwse: bool) -> Vec {
+ let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize];
+ let (lo, hi) = (3, CURSOR_SIZE - 4);
+ let (x0, y0, x1, y1) = if nwse { (lo, lo, hi, hi) } else { (lo, hi, hi, lo) };
+ draw_line(&mut buf, x0, y0, x1, y1, 2);
+ // Arrowheads: two short strokes angled off each end, perpendicular-ish
+ // to the main diagonal so they read as a `<` / `>`-style head.
+ let head = |buf: &mut [u8], hx: i32, hy: i32, ax1: i32, ay1: i32, ax2: i32, ay2: i32| {
+ draw_line(buf, hx, hy, ax1, ay1, 2);
+ draw_line(buf, hx, hy, ax2, ay2, 2);
+ };
+ if nwse {
+ head(&mut buf, x0, y0, x0 + 7, y0, x0, y0 + 7);
+ head(&mut buf, x1, y1, x1 - 7, y1, x1, y1 - 7);
+ } else {
+ head(&mut buf, x0, y0, x0 + 7, y0, x0, y0 - 7);
+ head(&mut buf, x1, y1, x1 - 7, y1, x1, y1 + 7);
+ }
+ add_white_halo(&mut buf);
+ buf
+}
+
+/// Solid-fills a rectangle, clamped to the canvas - used by [`pointer_bitmap`]
+/// instead of [`draw_line`]'s thin strokes, since a hand cursor reads better
+/// as a few blocky filled shapes than as an outline at this resolution.
+fn fill_rect(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32) {
+ for y in y0.max(0)..=y1.min(CURSOR_SIZE - 1) {
+ for x in x0.max(0)..=x1.min(CURSOR_SIZE - 1) {
+ set_px(buf, x, y);
+ }
+ }
+}
+
+/// A crosshair: full-height vertical line through full-width horizontal
+/// line, hotspot dead center where the two cross - `zwp_pointer_constraints`
+/// clients (games, precise pixel-editors) and any `cursor: crosshair` CSS
+/// both expect this exact shape.
+fn crosshair_bitmap() -> Vec {
+ let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize];
+ let mid = CURSOR_SIZE / 2;
+ draw_line(&mut buf, mid, 1, mid, CURSOR_SIZE - 2, 1);
+ draw_line(&mut buf, 1, mid, CURSOR_SIZE - 2, mid, 1);
+ add_white_halo(&mut buf);
+ buf
+}
+
+/// Four-way move arrow: one line from center to each edge, with an
+/// arrowhead at every tip - `cursor: move` (draggable panels, reordering
+/// lists), built the same way [`diagonal_resize_bitmap`] builds its two
+/// arrowheads, just aimed at all four cardinal directions instead of one
+/// diagonal.
+fn move_bitmap() -> Vec {
+ let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize];
+ let mid = CURSOR_SIZE / 2;
+ let (lo, hi) = (2, CURSOR_SIZE - 3);
+ draw_line(&mut buf, mid, lo, mid, hi, 2);
+ draw_line(&mut buf, lo, mid, hi, mid, 2);
+ let head = |buf: &mut [u8], hx: i32, hy: i32, ax1: i32, ay1: i32, ax2: i32, ay2: i32| {
+ draw_line(buf, hx, hy, ax1, ay1, 2);
+ draw_line(buf, hx, hy, ax2, ay2, 2);
+ };
+ head(&mut buf, mid, lo, mid - 4, lo + 5, mid + 4, lo + 5);
+ head(&mut buf, mid, hi, mid - 4, hi - 5, mid + 4, hi - 5);
+ head(&mut buf, lo, mid, lo + 5, mid - 4, lo + 5, mid + 4);
+ head(&mut buf, hi, mid, hi - 5, mid - 4, hi - 5, mid + 4);
+ add_white_halo(&mut buf);
+ buf
+}
+
+/// A blocky pointing hand: an upright index finger with the hotspot at its
+/// tip, above a wider palm block - `CursorIcon::Pointer`, the single most
+/// common named shape after the default arrow (every hyperlink, every
+/// clickable non-form control). Filled rectangles rather than an outline,
+/// since a recognisable hand silhouette needs more coverage than a few thin
+/// strokes can give at 24px.
+fn pointer_bitmap() -> Vec {
+ let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize];
+ fill_rect(&mut buf, 8, 1, 11, 11);
+ fill_rect(&mut buf, 4, 10, 19, 20);
+ add_white_halo(&mut buf);
+ buf
+}
+
+fn upload(data: Vec) -> 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(&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.
+pub(crate) const CENTERED_HOTSPOT: (i32, i32) = (CURSOR_SIZE / 2, CURSOR_SIZE / 2);
+
+pub(crate) fn make_buffers() -> CursorBuffers {
+ CursorBuffers {
+ arrow: make_buffer(),
+ 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()),
+ }
+}
+
+/// [`pointer_bitmap`]'s hotspot: the fingertip, near the top of the canvas
+/// - unlike the centered resize/crosshair/move shapes, a pointing hand's
+/// "active point" for click purposes is where the finger tip actually is,
+/// the same reasoning the built-in arrow's tip-at-origin hotspot already
+/// uses.
+const POINTER_HOTSPOT: (i32, i32) = (9, 1);
/// One cursor render element, whatever the source.
///
@@ -107,7 +401,7 @@ pub(crate) fn arrow_bitmap() -> Vec {
/// over this output - otherwise every monitor would draw its own copy.
pub(crate) fn render_elements(
status: &smithay::input::pointer::CursorImageStatus,
- buffer: &smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ buffers: &CursorBuffers,
renderer: &mut R,
pos: Point,
origin: Point,
@@ -121,7 +415,7 @@ where
{
use smithay::backend::renderer::element::surface::render_elements_from_surface_tree;
use smithay::backend::renderer::element::Kind;
- use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData};
+ use smithay::input::pointer::{CursorIcon, CursorImageStatus, CursorImageSurfaceData};
use smithay::wayland::compositor::with_states;
if matches!(status, CursorImageStatus::Hidden) {
@@ -148,9 +442,36 @@ where
return render_elements_from_surface_tree(renderer, surface, at, 1.0, 1.0, Kind::Cursor);
}
- // No client image (or a named shape we don't have art for): the
- // built-in arrow, whose hotspot is its tip, so no offset.
- let at = (local.0 as f64, local.1 as f64);
+ // 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
+ // don't draw, e.g. Grab/Pointer/Crosshair) falls back to the arrow,
+ // whose hotspot is its tip instead, at the origin.
+ let (buffer, at) = 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, local.1)),
+ },
+ _ => (&buffers.arrow, (local.0, local.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)],
Err(e) => {
@@ -201,4 +522,126 @@ mod tests {
assert!(black > 20, "expected a black outline, got {black} px");
assert!(white > 40, "expected a white fill, got {white} px");
}
+
+ #[test]
+ fn arrow_tail_is_a_single_tapering_shape_not_a_lopsided_fork() {
+ // Regression test: the tail below the arrowhead used to split into
+ // two separate legs of visibly different widths (one tapering to a
+ // point, the other a constant-width block that never tapered) --
+ // reported live as "one side is bigger than the other, not
+ // conventional at all". Each row of the tail must now be a single
+ // contiguous opaque run starting at column 0 (no gap splitting it
+ // into two pieces), and its width must never *grow* from the row
+ // above - a monotonic taper, not a fork.
+ let buf = arrow_bitmap();
+ let opaque_at = |x: usize, y: usize| buf[(y * CURSOR_SIZE as usize + x) * 4 + 3] != 0;
+ let mut prev_width: Option = None;
+ for y in 15..CURSOR_SIZE as usize {
+ let width = (0..CURSOR_SIZE as usize).take_while(|&x| opaque_at(x, y)).count();
+ if width == 0 {
+ continue;
+ }
+ assert!(opaque_at(0, y), "row {y}: tail must start flush at column 0");
+ for x in width..CURSOR_SIZE as usize {
+ assert!(!opaque_at(x, y), "row {y}: found opaque pixel at x={x} past a gap - tail has forked into two pieces");
+ }
+ if let Some(prev) = prev_width {
+ assert!(width <= prev, "row {y}: tail width grew from {prev} to {width} - not a monotonic taper");
+ }
+ prev_width = Some(width);
+ }
+ }
+
+ fn opaque_px_count(buf: &[u8]) -> usize {
+ buf.chunks_exact(4).filter(|px| px[3] != 0).count()
+ }
+
+ #[test]
+ fn text_bitmap_is_the_expected_size_and_draws_something() {
+ let buf = text_bitmap();
+ assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize);
+ assert!(opaque_px_count(&buf) > 10, "expected a visible I-beam");
+ }
+
+ #[test]
+ fn straight_resize_bitmaps_are_distinguishable_from_each_other() {
+ let horizontal = straight_resize_bitmap(true);
+ let vertical = straight_resize_bitmap(false);
+ assert!(opaque_px_count(&horizontal) > 10);
+ assert!(opaque_px_count(&vertical) > 10);
+ // A horizontal double-arrow and a vertical one should not paint the
+ // exact same pixels - if they did, `render_elements` would be
+ // silently showing the same shape for both directions.
+ assert_ne!(horizontal, vertical);
+ }
+
+ #[test]
+ fn diagonal_resize_bitmaps_are_distinguishable_from_each_other() {
+ let nwse = diagonal_resize_bitmap(true);
+ let nesw = diagonal_resize_bitmap(false);
+ assert!(opaque_px_count(&nwse) > 10);
+ assert!(opaque_px_count(&nesw) > 10);
+ assert_ne!(nwse, nesw);
+ }
+
+ fn has_white_px(buf: &[u8]) -> bool {
+ buf.chunks_exact(4).any(|px| px[3] != 0 && px[0] == 0xff && px[1] == 0xff && px[2] == 0xff)
+ }
+
+ /// The resize/text shapes are drawn as plain opaque black lines (see
+ /// `set_px`'s doc comment) - with no halo they would be solid black
+ /// with zero white pixels, i.e. nearly invisible over a dark window.
+ /// Confirmed live: a screenshot of the resize cursor over a black
+ /// terminal before this fix showed no visible shape at all. This test
+ /// would fail against that code.
+ #[test]
+ fn resize_and_text_shapes_get_a_visible_halo() {
+ assert!(has_white_px(&text_bitmap()), "I-beam has no white halo");
+ assert!(has_white_px(&straight_resize_bitmap(true)), "ew-resize has no white halo");
+ assert!(has_white_px(&straight_resize_bitmap(false)), "ns-resize has no white halo");
+ assert!(has_white_px(&diagonal_resize_bitmap(true)), "nwse-resize has no white halo");
+ assert!(has_white_px(&diagonal_resize_bitmap(false)), "nesw-resize has no white halo");
+ }
+
+ #[test]
+ fn crosshair_is_the_expected_size_and_draws_something() {
+ let buf = crosshair_bitmap();
+ assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize);
+ assert!(opaque_px_count(&buf) > 10, "expected a visible crosshair");
+ assert!(has_white_px(&buf), "crosshair has no white halo");
+ }
+
+ #[test]
+ fn move_icon_is_the_expected_size_and_draws_something() {
+ let buf = move_bitmap();
+ assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize);
+ assert!(opaque_px_count(&buf) > 10, "expected a visible move icon");
+ assert!(has_white_px(&buf), "move icon has no white halo");
+ }
+
+ #[test]
+ fn pointer_hand_is_the_expected_size_and_draws_something() {
+ let buf = pointer_bitmap();
+ assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize);
+ assert!(opaque_px_count(&buf) > 10, "expected a visible pointer hand");
+ assert!(has_white_px(&buf), "pointer hand has no white halo");
+ // The fingertip (the hotspot) must actually be opaque, same
+ // requirement the arrow's tip-pixel test already checks - otherwise
+ // the cursor would appear offset from where clicks land.
+ let (hx, hy) = POINTER_HOTSPOT;
+ let i = ((hy * CURSOR_SIZE + hx) * 4 + 3) as usize;
+ assert_eq!(buf[i], 0xff, "fingertip hotspot pixel must be opaque");
+ }
+
+ #[test]
+ fn crosshair_move_and_pointer_are_distinguishable_from_each_other_and_from_existing_shapes() {
+ let shapes = [crosshair_bitmap(), move_bitmap(), pointer_bitmap(), text_bitmap(), straight_resize_bitmap(true), diagonal_resize_bitmap(true)];
+ for (i, a) in shapes.iter().enumerate() {
+ for (j, b) in shapes.iter().enumerate() {
+ if i != j {
+ assert_ne!(a, b, "shapes {i} and {j} render identically");
+ }
+ }
+ }
+ }
}
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs
index fc722b7..8e897ef 100644
--- a/crates/wayland/src/decoration.rs
+++ b/crates/wayland/src/decoration.rs
@@ -14,6 +14,14 @@ use std::sync::OnceLock;
const FONT_PIXELS: f32 = 13.0;
const TEXT_LEFT_PADDING: f32 = 8.0;
+/// Titlebar buttons are laid out right-aligned in `height`-wide squares --
+/// matching `ResizeEdge::hit_test` in `crates/core/src/window.rs`, whose
+/// `BUTTON` constant is also `TITLEBAR_HEIGHT`. That function only computes
+/// *where* a click on close/maximize/minimize lands; nothing painted the
+/// buttons themselves, so the whole band was one undifferentiated bar with
+/// no visible way to tell where those three clickable regions were.
+const BUTTON_MARGIN: f32 = 0.32;
+
/// Common monospace font file locations on Linux desktops. Not a full
/// fontconfig query (no new system dependency for something this small) --
/// if none of these resolve, titlebars fall back to solid-color-only, same
@@ -85,12 +93,132 @@ fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] {
[rgb.2, rgb.1, rgb.0, alpha]
}
+/// The titlebar right-click window menu (minimize/maximize/always-on-top/
+/// close) - the one interaction virtually every desktop WM has always
+/// offered on a titlebar that srdwm never did (right-click there was only
+/// ever the SUPER+right-drag resize gesture, and only with the modifier
+/// held). `items` is `(label, highlighted)`; `row_height` matches
+/// `TITLEBAR_HEIGHT` by convention at the call site, not enforced here.
+///
+/// Deliberately plain: solid rows, left-padded text, a 1px border for
+/// definition against whatever's behind it - no submenus, no icons, no
+/// separators. A context menu widget with real visual polish is a project
+/// of its own; this is the minimum that makes the actions discoverable and
+/// clickable at all, which is the actual gap.
+pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)], bg: (u8, u8, u8), fg: (u8, u8, u8), highlight_bg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec {
+ let (width, row_height) = (width.max(1) as usize, row_height.max(1) as usize);
+ let height = (row_height * items.len().max(1)).max(1);
+ let mut buf = vec![0u8; width * height * 4];
+
+ let font = find_system_font();
+ for (i, (label, highlighted)) in items.iter().enumerate() {
+ let row_bg = if *highlighted { highlight_bg } else { bg };
+ let row_top = i * row_height;
+ for y in row_top..(row_top + row_height).min(height) {
+ for x in 0..width {
+ let idx = (y * width + x) * 4;
+ buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(row_bg, 255));
+ }
+ }
+ if let Some(font) = &font {
+ let baseline = row_top as f32 + row_height as f32 * 0.72;
+ let mut pen_x = 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, row_bg, fg);
+ }
+ pen_x += metrics.advance_width;
+ if pen_x as usize >= width {
+ break;
+ }
+ }
+ }
+ }
+
+ // A 1px border around the whole menu, drawn last so it isn't overdrawn
+ // by any row's background fill.
+ let border_px = rgb_to_bgra(border, 255);
+ for x in 0..width {
+ buf[x * 4..x * 4 + 4].copy_from_slice(&border_px);
+ let last_row = (height - 1) * width + x;
+ buf[last_row * 4..last_row * 4 + 4].copy_from_slice(&border_px);
+ }
+ for y in 0..height {
+ let left = y * width;
+ buf[left * 4..left * 4 + 4].copy_from_slice(&border_px);
+ let right = y * width + width - 1;
+ buf[right * 4..right * 4 + 4].copy_from_slice(&border_px);
+ }
+ buf
+}
+
+/// The four border strips (top, bottom, left, right) around a window's
+/// full rect, `width` thick, drawn *outside* `geometry` - additive to the
+/// window's on-screen footprint, the same as a native X11 border, rather
+/// than overlapping and clipping into the titlebar or content. This is
+/// purely a rendering concern: `geometry` alone stays authoritative for
+/// hit-testing and placement, nothing reads the strips back.
+///
+/// Without any border at all, a compositor-drawn titlebar and independently
+/// client-rendered content have nothing visually tying them together as
+/// one window - reported live as the titlebar "not seeming part of the
+/// window". `Window.border_color`/`border_width` already existed (and are
+/// drawn by the X11 backend via a native X11 border) but were dead fields
+/// on the Wayland side - `set_border_color`/`set_border_width` were both
+/// no-op stubs.
+pub fn border_strips(geometry: srdwm_core::Rect, width: u32) -> [srdwm_core::Rect; 4] {
+ let w = width as i32;
+ [
+ srdwm_core::Rect::new(geometry.x - w, geometry.y - w, geometry.width + 2 * width, width),
+ srdwm_core::Rect::new(geometry.x - w, geometry.y + geometry.height as i32, geometry.width + 2 * width, width),
+ srdwm_core::Rect::new(geometry.x - w, geometry.y, width, geometry.height),
+ srdwm_core::Rect::new(geometry.x + geometry.width as i32, geometry.y, width, geometry.height),
+ ]
+}
+
+/// Renders the top border strip (`border_strips`'s first rect) as a BGRA8
+/// bitmap instead of a plain solid fill, with its own outer top corners cut
+/// the same way `render_titlebar`'s `round_corners` cuts the titlebar's --
+/// see that parameter's doc comment for why a titlebar rounds but a square
+/// border frame around it used to defeat the point. Rounding *this* strip
+/// too, at a radius `width` pixels larger than the titlebar's (so the cut
+/// continues outward from the titlebar's own, rather than starting over),
+/// is what makes a bordered window's corner read as one continuous curve
+/// instead of a rounded titlebar sitting inside a square frame. The other
+/// three strips (bottom/left/right) don't participate in any visible
+/// corner and stay plain solid fills - see their render call sites.
+pub fn render_border_top(width: u32, thickness: u32, color: (u8, u8, u8)) -> Vec {
+ 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);
+ buf
+}
+
/// Renders a `width x height` BGRA8 buffer: filled with `background`, with
/// `title` drawn left-aligned in `foreground` (best-effort glyph layout --
/// no text shaping/kerning, adequate for the ASCII-heavy titles window
/// managers actually display). Returns `None` (caller keeps the previous
/// solid-color-only look) only if no usable font was found on this system.
-pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8, u8), foreground: (u8, u8, u8)) -> Vec {
+///
+/// `round_corners` should be `false` only for a window whose border strips
+/// are rendered as plain square-cornered fills with no matching rounded
+/// treatment of their own. `render_border_top` gives the border's top strip
+/// the same rounded-corner cut (see its own doc comment for how the two
+/// stay visually continuous), so a normal bordered window should pass
+/// `true` here same as a borderless one now - reported live as most
+/// windows (anything with the default border) looking inconsistently
+/// square next to the few borderless ones that were rounded.
+pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8, u8), foreground: (u8, u8, u8), round_corners: bool) -> Vec {
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];
@@ -98,26 +226,154 @@ pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8
px.copy_from_slice(&bg);
}
- let Some(font) = find_system_font() else { return buf };
+ // Reserve the right-hand button squares before laying out text, so a
+ // long title elides under them the same way it would under real window
+ // furniture rather than drawing on top of it.
+ let button_count = if width >= height * 3 { 3 } else { 0 };
+ let text_limit = width.saturating_sub(height * button_count);
- let baseline = (height as f32 * 0.72).round();
- let mut pen_x = TEXT_LEFT_PADDING;
- for ch in title.chars() {
- if ch.is_control() {
- continue;
+ if let Some(font) = find_system_font() {
+ let baseline = (height as f32 * 0.72).round();
+ let mut pen_x = TEXT_LEFT_PADDING;
+ for ch in title.chars() {
+ if ch.is_control() {
+ continue;
+ }
+ let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
+ if metrics.width > 0 && metrics.height > 0 {
+ let glyph_x = pen_x + metrics.xmin as f32;
+ let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32;
+ blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, background, foreground);
+ }
+ pen_x += metrics.advance_width;
+ if pen_x as usize >= text_limit {
+ break;
+ }
+ }
+ }
+
+ if button_count == 3 {
+ draw_minimize_icon(&mut buf, width, height, height * 2, foreground);
+ draw_maximize_icon(&mut buf, width, height, height, foreground);
+ draw_close_icon(&mut buf, width, height, 0, foreground);
+ }
+ if round_corners {
+ round_top_corners(&mut buf, width, height, CORNER_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.
+const CORNER_RADIUS: u32 = 6;
+
+/// Clips the top-left and top-right corners of a titlebar buffer to a
+/// quarter-circle by making the pixels outside it fully transparent, so
+/// whatever's behind (the desktop, on every top-level window) shows through
+/// instead of a hard square corner.
+///
+/// Only the *top* corners: the titlebar's bottom edge meets the window's
+/// content, which this compositor has no way to clip (content is rendered
+/// entirely by the client) - rounding that seam too would need a
+/// compositor-wide clip mask over arbitrary client buffers, a much larger
+/// change than this cosmetic pass. Real desktops mostly round this the same
+/// way: only the outermost corners of a window, not every internal seam.
+///
+/// Hard cutoff rather than an anti-aliased edge, matching this codebase's
+/// existing pixel-art aesthetic elsewhere (the cursor bitmaps) rather than
+/// mixing rendering styles for one corner treatment.
+fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) {
+ let r = (radius as usize).min(width / 2).min(height);
+ if r == 0 {
+ return;
+ }
+ // Corner centres: `r` in from each edge, `r` down from the top - the
+ // standard quarter-circle-in-a-square construction.
+ let is_outside_corner = |x: usize, y: usize, cx: usize, cy: usize| -> bool {
+ let (dx, dy) = (x as i64 - cx as i64, y as i64 - cy as i64);
+ (dx * dx + dy * dy) as u64 > (r * r) as u64
+ };
+ for y in 0..r {
+ for x in 0..r {
+ if is_outside_corner(x, y, r, r) {
+ buf[(y * width + x) * 4 + 3] = 0;
+ }
}
- let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
- if metrics.width > 0 && metrics.height > 0 {
- let glyph_x = pen_x + metrics.xmin as f32;
- let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32;
- blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, background, foreground);
+ for x in (width - r)..width {
+ if is_outside_corner(x, y, width - r - 1, r) {
+ buf[(y * width + x) * 4 + 3] = 0;
+ }
}
- pen_x += metrics.advance_width;
- if pen_x as usize >= width {
+ }
+}
+
+/// Sets one pixel to `color` if it falls inside the buffer - every icon
+/// drawn below goes through this so none of them need their own bounds
+/// checks.
+fn set_px(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: (u8, u8, u8)) {
+ if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
+ return;
+ }
+ let idx = (y as usize * width + x as usize) * 4;
+ buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(color, 255));
+}
+
+/// The square `right_offset` pixels in from the right edge of the titlebar,
+/// inset by `BUTTON_MARGIN` on each side - the box a button's glyph is
+/// drawn inside.
+fn button_box(width: usize, height: usize, right_offset: usize) -> (i32, i32, i32, i32) {
+ let square = height as f32;
+ let inset = (square * BUTTON_MARGIN).round() as i32;
+ let right = width as i32 - right_offset as i32;
+ let left = right - height as i32;
+ (left + inset, inset, right - inset, height as i32 - inset)
+}
+
+/// Bresenham line, since none of these icons need anything fancier.
+fn draw_line(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: (u8, u8, u8)) {
+ let (mut x0, mut y0) = (x0, y0);
+ let dx = (x1 - x0).abs();
+ let dy = -(y1 - y0).abs();
+ let sx = if x0 < x1 { 1 } else { -1 };
+ let sy = if y0 < y1 { 1 } else { -1 };
+ let mut err = dx + dy;
+ loop {
+ set_px(buf, width, height, x0, y0, color);
+ if x0 == x1 && y0 == y1 {
break;
}
+ let e2 = 2 * err;
+ if e2 >= dy {
+ err += dy;
+ x0 += sx;
+ }
+ if e2 <= dx {
+ err += dx;
+ y0 += sy;
+ }
}
- buf
+}
+
+fn draw_close_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = button_box(width, height, right_offset);
+ draw_line(buf, width, height, x0, y0, x1, y1, color);
+ draw_line(buf, width, height, x0, y1, x1, y0, color);
+}
+
+fn draw_maximize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = button_box(width, height, right_offset);
+ draw_line(buf, width, height, x0, y0, x1, y0, color);
+ draw_line(buf, width, height, x0, y1, x1, y1, color);
+ draw_line(buf, width, height, x0, y0, x0, y1, color);
+ draw_line(buf, width, height, x1, y0, x1, y1, color);
+}
+
+fn draw_minimize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
+ let (x0, _, x1, y1) = button_box(width, height, right_offset);
+ draw_line(buf, width, height, x0, y1, x1, y1, color);
}
#[allow(clippy::too_many_arguments)]
@@ -160,11 +416,57 @@ fn blit_glyph(
mod tests {
use super::*;
+ #[test]
+ fn border_strips_surround_geometry_without_overlapping_it() {
+ let geom = srdwm_core::Rect::new(100, 100, 200, 150);
+ let [top, bottom, left, right] = border_strips(geom, 3);
+ // Every strip's own rect must stay entirely outside `geom` - these
+ // are meant to frame the window, not clip into its own titlebar or
+ // content.
+ assert_eq!(top, srdwm_core::Rect::new(97, 97, 206, 3));
+ assert_eq!(bottom, srdwm_core::Rect::new(97, 250, 206, 3));
+ assert_eq!(left, srdwm_core::Rect::new(97, 100, 3, 150));
+ assert_eq!(right, srdwm_core::Rect::new(300, 100, 3, 150));
+ }
+
#[test]
fn fills_background_when_no_text() {
- let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4));
+ let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true);
assert_eq!(buf.len(), 40 * 20 * 4);
- assert_eq!(&buf[0..4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255));
+ // Center, not (0,0): the top-left pixel is inside the rounded
+ // corner `round_top_corners` clips away, so it's transparent by
+ // design - see `corners_are_clipped_but_the_middle_is_not` below.
+ let mid = ((20 / 2) * 40 + 40 / 2) * 4;
+ assert_eq!(&buf[mid..mid + 4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255));
+ }
+
+ #[test]
+ fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() {
+ // Regression test for a bug where every drawn icon was one full
+ // button-width left of where a click on it actually landed: the
+ // visible "X" triggered Maximize, the visible square triggered
+ // Minimize, and the true Close hit-zone (the rightmost
+ // TITLEBAR_HEIGHT-wide band) was blank. `button_box`'s
+ // `right_offset` must put each icon in the same square
+ // `ResizeEdge::hit_test` assigns to it - checked here by picking
+ // the centre pixel of each drawn icon's square and confirming
+ // `hit_test` reports the matching button for that same point.
+ let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let buf = render_titlebar(width, height, "", bg, fg, true);
+ let frame = srdwm_core::Rect::new(0, 0, width, height);
+ let (width, height) = (width as usize, height as usize);
+
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ for (right_offset, expected) in [(0, srdwm_core::TitlebarHit::Close), (height, srdwm_core::TitlebarHit::Maximize), (height * 2, srdwm_core::TitlebarHit::Minimize)] {
+ let (x0, y0, x1, y1) = button_box(width, height, right_offset);
+ let drawn = (y0..=y1).any(|y| (x0..=x1).any(|x| buf[(y as usize * width + x as usize) * 4..(y as usize * width + x as usize) * 4 + 4] != bg_bytes));
+ assert!(drawn, "expected some drawn icon pixel inside the right_offset={right_offset} square");
+ let cx = (x0 + x1) / 2;
+ let cy = (y0 + y1) / 2;
+ assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0), Some(expected), "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}");
+ }
}
#[test]
@@ -175,17 +477,110 @@ mod tests {
}
let bg = (0x2e, 0x34, 0x40);
let fg = (0xec, 0xef, 0xf4);
- let buf = render_titlebar(200, 30, "Terminal", bg, fg);
+ let buf = render_titlebar(200, 30, "Terminal", bg, fg, true);
let bg_bytes = rgb_to_bgra(bg, 255);
let changed = buf.chunks_exact(4).any(|px| px != bg_bytes);
assert!(changed, "expected at least one pixel to differ from the background once text is drawn");
}
#[test]
- fn empty_title_leaves_buffer_all_background() {
+ fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() {
let bg = (0x10, 0x20, 0x30);
- let buf = render_titlebar(50, 24, "", bg, (0xff, 0xff, 0xff));
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true);
let bg_bytes = rgb_to_bgra(bg, 255);
- assert!(buf.chunks_exact(4).all(|px| px == bg_bytes));
+ for (i, px) in buf.chunks_exact(4).enumerate() {
+ let (x, y) = (i % width as usize, i / width as usize);
+ let in_top_left = x < CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
+ let in_top_right = x >= width as usize - CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
+ if !in_top_left && !in_top_right {
+ assert_eq!(px, bg_bytes, "unexpected non-background pixel at ({x}, {y})");
+ }
+ }
+ }
+
+ #[test]
+ fn corners_are_clipped_but_the_middle_is_not() {
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ // The very corner pixel is well outside the quarter-circle at any
+ // sane radius - fully clipped.
+ assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be transparent");
+ assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be transparent");
+ // Bottom corners are deliberately left square (see the function's
+ // doc comment: the titlebar's bottom edge meets client content,
+ // which can't be clipped the same way).
+ assert_eq!(alpha_at(0, height as usize - 1), 255, "bottom-left must stay square");
+ assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255, "bottom-right must stay square");
+ // Centre is nowhere near either corner circle - untouched.
+ assert_eq!(alpha_at(width as usize / 2, height as usize / 2), 255);
+ }
+
+ #[test]
+ fn 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 alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255, "top-left corner should stay square when round_corners is false");
+ assert_eq!(alpha_at(width as usize - 1, 0), 255, "top-right corner should stay square when round_corners is false");
+ }
+
+ #[test]
+ fn border_top_rounds_its_own_top_corners_to_match_the_titlebar() {
+ // Regression coverage for the "not all window borders are rounded"
+ // report: a bordered window's titlebar used to render with
+ // `round_corners = false` specifically to avoid clashing with this
+ // strip's square corners. Now that this strip rounds too, that
+ // workaround is gone (`render_titlebar` is always called with
+ // `true`) - this just confirms the strip actually does what that
+ // change now depends on.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness) = (60, 2);
+ let buf = render_border_top(width, thickness, color);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be clipped");
+ assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be clipped");
+ // A 2px-thick strip is thinner than any sane radius, so the clamp
+ // in `round_top_corners` bounds the cut to the strip's own height --
+ // the bottom row, at least at the strip's horizontal centre, must
+ // stay opaque or there would be no border left to see at all.
+ assert_eq!(alpha_at(width as usize / 2, thickness as usize - 1), 255, "centre of the strip must stay opaque");
+ }
+
+ #[test]
+ fn context_menu_is_one_row_tall_per_item() {
+ let items = [("Minimize", false), ("Maximize", false), ("Always on Top", false), ("Close", false)];
+ let buf = render_context_menu(160, 28, &items, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x4c, 0x56, 0x6a), (0x10, 0x10, 0x10));
+ assert_eq!(buf.len(), 160 * (28 * 4) * 4);
+ }
+
+ #[test]
+ fn context_menu_highlighted_row_has_a_different_background_than_the_rest() {
+ let items = [("Minimize", false), ("Close", true)];
+ let bg = (0x2e, 0x34, 0x40);
+ let highlight = (0x4c, 0x56, 0x6a);
+ let buf = render_context_menu(160, 28, &items, bg, (0xff, 0xff, 0xff), highlight, (0x10, 0x10, 0x10));
+ let width = 160usize;
+ // Sample a background pixel from each row, away from the text/border.
+ let px_at = |x: usize, y: usize| -> [u8; 3] {
+ let i = (y * width + x) * 4;
+ [buf[i + 2], buf[i + 1], buf[i]] // BGRA -> RGB
+ };
+ assert_eq!(px_at(100, 5), [bg.0, bg.1, bg.2], "row 0 (not highlighted) should use bg");
+ assert_eq!(px_at(100, 33), [highlight.0, highlight.1, highlight.2], "row 1 (highlighted) should use highlight_bg");
+ }
+
+ #[test]
+ fn context_menu_border_is_opaque_at_every_edge() {
+ let items = [("Close", false)];
+ let buf = render_context_menu(100, 28, &items, (0, 0, 0), (0xff, 0xff, 0xff), (0, 0, 0), (0x99, 0x99, 0x99));
+ let alpha_at = |x: usize, y: usize| buf[(y * 100 + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255);
+ assert_eq!(alpha_at(99, 0), 255);
+ assert_eq!(alpha_at(0, 27), 255);
+ assert_eq!(alpha_at(99, 27), 255);
}
}
diff --git a/crates/wayland/src/elements.rs b/crates/wayland/src/elements.rs
index f1fbfee..f9a569e 100644
--- a/crates/wayland/src/elements.rs
+++ b/crates/wayland/src/elements.rs
@@ -1,14 +1,24 @@
//! The one render-element type for everything srdwm draws *itself*, on top
-//! of client windows: its titlebars and the mouse pointer.
+//! of client windows: its titlebars, borders, and the mouse pointer.
//!
//! `render_output` takes a single `custom_elements` slice, so these have to
-//! be one type. They come from two different sources - an uploaded bitmap
-//! (titlebars, the built-in cursor arrow) and a client's own surface (a
-//! client-set cursor image) - hence the two variants.
+//! be one type. They come from three different sources - an uploaded
+//! bitmap (titlebars, the built-in cursor arrow), a client's own surface (a
+//! client-set cursor image), and a native solid-colour fill (borders) --
+//! hence the three variants.
use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
-use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
-use smithay::backend::renderer::{ImportAll, ImportMem};
+use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
+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::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::utils::{Physical, Point, Rectangle, Scale};
+
+use srdwm_core::TITLEBAR_HEIGHT;
+
+use crate::state::CompState;
smithay::backend::renderer::element::render_elements! {
pub(crate) OverlayElement where
@@ -17,4 +27,211 @@ smithay::backend::renderer::element::render_elements! {
Surface=WaylandSurfaceRenderElement,
/// A bitmap srdwm rasterised: a titlebar, or the built-in cursor arrow.
Memory=MemoryRenderBufferRenderElement,
+ /// A native solid-colour fill - window borders. Deliberately not a
+ /// stretched 1x1 `Memory` buffer (`border_strips`' previous approach):
+ /// `PixmanRenderer` (the udev backend's software renderer) hardcodes
+ /// `Repeat::None` on every imported texture, so sampling a 1x1 texture
+ /// stretched across a larger destination has no valid neighbouring
+ /// texels to fall back on and renders fully transparent - the border
+ /// silently never appeared on real hardware, only on the winit/GLES
+ /// backend (GPU texture sampling of a 1x1 texture returns that texel
+ /// regardless of wrap mode, so it happened to work there by accident).
+ /// `SolidColorRenderElement` goes through `Frame::draw_solid`, a native
+ /// fill with no texture import or sampling involved at all, so this
+ /// backend difference cannot affect it.
+ Solid=SolidColorRenderElement,
+}
+
+/// One border strip as a native solid-colour fill, `origin`-translated to
+/// output-local space - the same convention every other `custom_elements`
+/// entry (cursor, decorations, popups) already uses. No renderer/texture
+/// import needed at all (unlike the `Memory`-buffer approach this replaced).
+///
+/// Takes a *persistent* `SolidColorBuffer` (one kept per window per strip in
+/// `CompState::border_side_buffers`, updated in place here) rather than
+/// building a element with a fresh `Id`/`CommitCounter` every call, which an
+/// earlier version of this function did on the reasoning that `Id`'s only
+/// role is picking a *tighter* damage rect and a border strip is cheap to
+/// redraw in full regardless. That undersold the actual cost: smithay's
+/// `OutputDamageTracker::damage_output_internal` looks up each element's
+/// previous state by `Id` and falls back to `.unwrap_or(true)` ("damage it")
+/// when no match is found - a fresh `Id` every frame means *every* frame
+/// finds no match, so every bordered window's border is marked damaged on
+/// *every* frame forever, not occasionally with a wider rect. Confirmed by
+/// reading `damage/mod.rs`'s `damage_output_internal` directly (0.7.0):
+/// `element_last_state.map(|s| !s.instance_matches(...)).unwrap_or(true)`.
+/// Downstream, `render_udev_frame`'s `has_damage` gate around the real DRM
+/// page flip is then unconditionally true every ~16ms for as long as any
+/// window with `border_width > 0` is on screen - effectively every window,
+/// since that is the default - so the output never actually goes idle.
+/// `SolidColorBuffer::update` only bumps its internal `CommitCounter` when
+/// the size or colour actually changed, so reusing the same buffer (and
+/// therefore the same stable `Id`) here is what lets the tracker correctly
+/// see "nothing changed" and skip real work on a static screen.
+pub(crate) fn border_side_render_element(buf: &mut SolidColorBuffer, strip: srdwm_core::Rect, color: (u8, u8, u8), origin: (i32, i32)) -> SolidColorRenderElement {
+ let c = Color32F::new(color.0 as f32 / 255.0, color.1 as f32 / 255.0, color.2 as f32 / 255.0, 1.0);
+ buf.update((strip.width as i32, strip.height as i32), c);
+ let loc = Point::from((strip.x - origin.0, strip.y - origin.1));
+ SolidColorRenderElement::from_buffer(buf, loc, 1.0, 1.0, Kind::Unspecified)
+}
+
+/// Splits a border strip into the sub-rectangles still visible after
+/// subtracting every window rect stacked in front of it.
+///
+/// Border strips render via `custom_elements`, which `smithay::desktop::
+/// space::render_output` always composites above *every* window's own
+/// content (drawn separately, via `self.space`) - unconditionally, with
+/// no way to interleave the two by real stacking order (confirmed by
+/// reading `render_output`'s own source: `custom_elements` are pushed into
+/// the final element list before `space_render_elements`, and this
+/// backend's damage tracker treats earlier-pushed elements as topmost, the
+/// same convention `cursor::render_elements`' own doc comment already
+/// relies on). Without this, a background window's border rendered as a
+/// solid line straight through a foreground window's content wherever the
+/// two visually overlapped - confirmed live: a stack of cascaded
+/// terminals showed every earlier window's right border strip as a set of
+/// vertical lines cutting across the frontmost window's own content.
+/// `occluders` should already be limited to windows stacked above the one
+/// this border belongs to.
+pub(crate) fn visible_border_fragments(strip: srdwm_core::Rect, occluders: &[srdwm_core::Rect]) -> Vec {
+ strip.subtract_all(occluders)
+}
+
+/// Gets (growing if needed) the `index`th persistent buffer in a window's
+/// border-fragment pool - see `CompState::border_side_buffers`' doc
+/// comment for why this is a growable pool rather than a fixed-size array.
+pub(crate) fn border_fragment_buffer(pool: &mut Vec, index: usize) -> &mut SolidColorBuffer {
+ if index >= pool.len() {
+ pool.resize_with(index + 1, SolidColorBuffer::default);
+ }
+ &mut pool[index]
+}
+
+/// A mapped toplevel's surface and on-screen (global-space, band-adjusted)
+/// position - everything [`popup_render_elements`] needs to find and place
+/// that window's popups, pre-extracted from `CompState` by
+/// [`popup_targets`] so the actual rendering call can run after
+/// `self.udev`/`self.backend` is mutably borrowed for its renderer, the
+/// same "gather immutable state first" split `render_udev_frame` already
+/// uses for borders and decorations (see its own comment).
+pub(crate) struct PopupTarget {
+ surface: WlSurface,
+ window_pos: (i32, i32),
+}
+
+/// Only windows `visible_windows()` would show are considered - a popup
+/// belonging to a workspace-hidden or minimized parent has no business
+/// appearing on screen either.
+///
+/// Also gathers every mapped layer-shell surface (bars, launchers) across
+/// every output, not just toplevel windows - a popup can be parented to
+/// either (`zwlr_layer_surface_v1.get_popup`, used for a bar's own
+/// dropdown/context menu, is a completely separate request from
+/// `xdg_surface.get_popup`, and this function previously only ever
+/// accounted for the latter). `PopupManager` itself already tracked and
+/// configured layer-surface-parented popups correctly (that path goes
+/// through the same `XdgShellHandler::new_popup`/`commit()` regardless of
+/// what the eventual parent turns out to be) - they just never had a
+/// `PopupTarget` to render relative to, so they were fully functional and
+/// completely invisible: a bar's dropdown menu would open, accept clicks,
+/// and never draw a single pixel.
+pub(crate) fn popup_targets(state: &CompState) -> Vec {
+ let wm = state.wm.borrow();
+ let current = wm.current_workspace();
+ let windows = state.id_to_window.iter().filter_map(|(&id, dwindow)| {
+ let w = wm.window(id)?;
+ if w.minimized || w.workspace != current {
+ return None;
+ }
+ let toplevel = dwindow.toplevel()?;
+ let band = if w.decorated { TITLEBAR_HEIGHT as i32 } else { 0 };
+ Some(PopupTarget { surface: toplevel.wl_surface().clone(), window_pos: (w.geometry.x, w.geometry.y + band) })
+ });
+ let layers = state.outputs.iter().flat_map(|entry| {
+ let origin = entry.location;
+ let map = layer_map_for_output(&entry.output);
+ map.layers()
+ .filter_map(|layer| {
+ let geo = map.layer_geometry(layer)?;
+ Some(PopupTarget { surface: layer.wl_surface().clone(), window_pos: (origin.x + geo.loc.x, origin.y + geo.loc.y) })
+ })
+ .collect::>()
+ });
+ windows.chain(layers).collect()
+}
+
+/// Every currently-mapped `xdg_popup`'s surface tree, positioned relative to
+/// its parent toplevel - tooltips, dropdown menus, right-click menus. Not
+/// part of `space` (popups are never `space.map_element`'d, only their
+/// parent toplevel is), so `render_output`'s automatic per-space-element
+/// rendering never sees them; this is what makes them show up at all now
+/// that `protocols.rs`'s `new_popup` actually configures them instead of
+/// hanging the client forever.
+///
+/// `origin` is the output's location in global space, same as every other
+/// `custom_elements` entry (cursor, borders, decorations) already
+/// subtracts - positions here have to be output-local too, or a popup on
+/// any output but the one at the global origin renders offset by that
+/// output's own placement.
+pub(crate) fn popup_render_elements(targets: &[PopupTarget], renderer: &mut R, origin: (i32, i32)) -> Vec>
+where
+ R: Renderer + ImportAll + ImportMem,
+ R::TextureId: Clone + Send + 'static,
+{
+ let mut elements = Vec::new();
+ for target in targets {
+ for (popup, offset) in PopupManager::popups_for_surface(&target.surface) {
+ // Both `Xdg` (tooltips, dropdown/context menus) and
+ // `InputMethod` (an IME's composition/candidate window --
+ // registered via `protocols.rs`'s `InputMethodHandler::
+ // new_popup`) render identically here: `PopupManager` already
+ // tracks both uniformly keyed by parent surface, and `offset`
+ // is each kind's own on-screen position relative to
+ // `target.window_pos` regardless of which one it is.
+ let loc = (target.window_pos.0 - origin.0 + offset.x, target.window_pos.1 - origin.1 + offset.y);
+ elements.extend(render_elements_from_surface_tree(renderer, popup.wl_surface(), loc, 1.0, 1.0, Kind::Unspecified));
+ }
+ }
+ elements
+}
+
+/// 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.
+///
+/// Both backends used to call `send_frame` on *every* mapped window
+/// whenever the output had any damage at all, including damage from
+/// nothing but the cursor moving. `has-damage` is an output-wide gate; it
+/// says nothing about *where* the damage was, so a window nowhere near the
+/// cursor was told just as urgently to redraw as one it was passing over.
+/// Any client using the standard wait-for-frame-callback pattern (most of
+/// them, confirmed live: wezterm-gui pinned at 140%+ CPU on a fully idle
+/// terminal) had no reason not to redraw every single time, forever. This
+/// narrows the callback to windows the damage rectangles actually
+/// intersect, which is what makes a stationary cursor's damage stop
+/// waking up windows it never touched.
+///
+/// Deliberately does *not* also cover the focused/hovered-window starvation
+/// case (an idle window's own pending callback never getting answered
+/// because nothing else ever damages its bounds) - an earlier version of
+/// this function took an `always_notify` list for that, but folding it in
+/// here meant it only ever ran on a tick that already had damage from
+/// something else, i.e. never on the fully-idle tick it was meant for. See
+/// the caller: that case is now handled by a second, always-unconditional
+/// pass over the focused/hovered windows specifically, independent of
+/// whether this function's damage-gated pass runs at all this tick.
+pub(crate) fn windows_touched_by_damage<'a>(
+ space: &'a Space,
+ damage: &'a [Rectangle],
+ scale: Scale,
+) -> impl Iterator- + 'a {
+ space.elements().filter(move |w| {
+ space
+ .element_geometry(w)
+ .map(|geo| {
+ let phys = geo.to_physical_precise_round(scale);
+ damage.iter().any(|d| d.overlaps(phys))
+ })
+ .unwrap_or(false)
+ })
}
diff --git a/crates/wayland/src/foreign_toplevel.rs b/crates/wayland/src/foreign_toplevel.rs
new file mode 100644
index 0000000..c6913a5
--- /dev/null
+++ b/crates/wayland/src/foreign_toplevel.rs
@@ -0,0 +1,379 @@
+//! `wlr-foreign-toplevel-management-unstable-v1`: enumerates every managed
+//! window (title, app_id, activated/maximized/minimized state) to any
+//! client that binds it, and lets that client request activation, close,
+//! maximize/minimize - what a dock or taskbar needs to be interactive
+//! rather than just a launcher. Requested by AGS's dock (see
+//! `docs/PANEL_SUPPORT_TODO.md`'s P1 list): without it there is no running-
+//! app indicator, no click-to-focus in the dock, no alt-tab list.
+//!
+//! Chose this over the newer split `ext_foreign_toplevel_list_v1` (list
+//! only) + a separate management protocol: the wlr protocol covers
+//! enumeration *and* activate/close/maximize/minimize in one interface, and
+//! it is what was actually asked for - smithay 0.7 has no built-in helper
+//! for either shape, but has one for the `ext_` list-only half, which would
+//! have meant standing up two protocols to get what this one already does
+//! alone. Hand-written against the raw `wayland-protocols-wlr` server
+//! bindings, same reasoning and the same crate as `screencopy.rs`.
+//!
+//! One `ZwlrForeignToplevelHandleV1` is created per (bound manager, window)
+//! pair - a dock running in one client only ever sees the handles that
+//! client's own manager object created, so `CompState` tracks a `Vec` of
+//! live handles per `WindowId` to broadcast state changes (activation,
+//! maximized, minimized, fullscreen) to every one of them, from whichever
+//! call site actually changed that state - this module's own requests,
+//! the pointer-driven titlebar handlers, or a client's own
+//! `xdg_toplevel`/X11 request. See `update_activated`'s doc comment for
+//! the one trigger that still doesn't re-broadcast.
+//!
+//! Override-redirect X11 windows are deliberately never announced here,
+//! matching `_NET_CLIENT_LIST` and ICCCM: a dock has no business listing
+//! menus/tooltips/drag images as if they were real applications.
+
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
+use wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_handle_v1::{self, State, ZwlrForeignToplevelHandleV1};
+use wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1};
+
+use srdwm_core::WindowId;
+
+use crate::input::{close_dwindow, focus_window};
+use crate::state::CompState;
+
+const PROTOCOL_VERSION: u32 = 3;
+
+pub struct ForeignToplevelState {
+ _global: smithay::reexports::wayland_server::backend::GlobalId,
+}
+
+impl ForeignToplevelState {
+ pub fn new(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch + 'static,
+ {
+ Self { _global: dh.create_global::(PROTOCOL_VERSION, ()) }
+ }
+}
+
+/// Per-handle data: which window this specific `ZwlrForeignToplevelHandleV1`
+/// (one of possibly several, one per bound manager) speaks for.
+pub struct ToplevelHandleData {
+ window: WindowId,
+}
+
+impl GlobalDispatch for CompState {
+ fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ let manager = data_init.init(manager, ());
+ // Catch this client's dock/switcher up on every window that already
+ // exists - without this, only windows opened *after* the client
+ // bound the global would ever be announced.
+ let existing: Vec = state.wm.borrow().windows().map(|w| w.id).collect();
+ for id in existing {
+ announce(state, &manager, id, client, dh);
+ }
+ state.foreign_toplevel_managers.push(manager);
+ }
+}
+
+impl Dispatch for CompState {
+ fn request(state: &mut Self, _client: &Client, manager: &ZwlrForeignToplevelManagerV1, request: zwlr_foreign_toplevel_manager_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) {
+ if let zwlr_foreign_toplevel_manager_v1::Request::Stop = request {
+ manager.finished();
+ state.foreign_toplevel_managers.retain(|m| m != manager);
+ }
+ }
+
+ fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, manager: &ZwlrForeignToplevelManagerV1, _data: &()) {
+ state.foreign_toplevel_managers.retain(|m| m != manager);
+ }
+}
+
+impl Dispatch for CompState {
+ fn request(
+ state: &mut Self,
+ _client: &Client,
+ _handle: &ZwlrForeignToplevelHandleV1,
+ request: zwlr_foreign_toplevel_handle_v1::Request,
+ data: &ToplevelHandleData,
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, Self>,
+ ) {
+ let id = data.window;
+ // Every branch that actually changes state re-announces it
+ // (`send_state`) rather than waiting for some other path to notice
+ // - `activate`/`close` are the two the dock actually needs (per
+ // `docs/PANEL_SUPPORT_TODO.md`), the rest are handled for protocol
+ // completeness since a dock is free to send them (a maximize
+ // context-menu entry, say) even if AGS's dock doesn't today.
+ match request {
+ zwlr_foreign_toplevel_handle_v1::Request::Activate { seat: _ } => {
+ // No per-seat distinction - this compositor has exactly
+ // one seat, same simplification `srd.window.focus()` and
+ // every other single-window-target API already makes.
+ focus_window(state, id);
+ }
+ zwlr_foreign_toplevel_handle_v1::Request::Close => {
+ if let Some(w) = state.id_to_window.get(&id) {
+ close_dwindow(w);
+ }
+ }
+ zwlr_foreign_toplevel_handle_v1::Request::SetMaximized => set_maximized(state, id, true),
+ zwlr_foreign_toplevel_handle_v1::Request::UnsetMaximized => set_maximized(state, id, false),
+ zwlr_foreign_toplevel_handle_v1::Request::SetMinimized => set_minimized(state, id, true),
+ zwlr_foreign_toplevel_handle_v1::Request::UnsetMinimized => set_minimized(state, id, false),
+ // `output` is a hint only (per the protocol doc: "only a hint to
+ // the compositor") - ignored, same as `fullscreen_request`'s
+ // `_output` in `protocols.rs`, and for the same reason: single-
+ // seat, single-fullscreen-path, matching every other fullscreen
+ // entry point instead of introducing an output-aware one just
+ // for this request.
+ zwlr_foreign_toplevel_handle_v1::Request::SetFullscreen { output: _ } => set_fullscreen(state, id, true),
+ zwlr_foreign_toplevel_handle_v1::Request::UnsetFullscreen => set_fullscreen(state, id, false),
+ // The rectangle hint and Destroy: no srdwm-side effect. The
+ // rectangle is an optional hint we're not obligated to act on;
+ // `Destroy` just ends this protocol object, handled by
+ // `destroyed` below.
+ _ => {}
+ }
+ }
+
+ fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, handle: &ZwlrForeignToplevelHandleV1, data: &ToplevelHandleData) {
+ if let Some(handles) = state.foreign_toplevel_handles.get_mut(&data.window) {
+ handles.retain(|h| h != handle);
+ }
+ }
+}
+
+fn set_maximized(state: &mut CompState, id: WindowId, maximized: bool) {
+ let already = state.wm.borrow().window(id).is_some_and(|w| w.maximized);
+ if already != maximized {
+ state.wm.borrow_mut().toggle_maximize(id);
+ state.sync_geometry(id);
+ }
+ send_state(state, id);
+}
+
+fn set_minimized(state: &mut CompState, id: WindowId, minimized: bool) {
+ let mut wm = state.wm.borrow_mut();
+ if minimized {
+ wm.minimize_window(id);
+ } else {
+ wm.restore_window(id);
+ }
+ drop(wm);
+ send_state(state, id);
+}
+
+/// Same `redraw_decoration_buffer`-before-`sync_geometry` ordering as every
+/// other fullscreen entry point (`protocols.rs`'s `fullscreen_request`,
+/// `xwayland.rs`'s equivalent): `toggle_fullscreen` also flips
+/// `Window.decorated`, and the decoration buffer needs to actually be
+/// dropped, not just left stale for `sync_geometry`'s resize-only redraw
+/// check to skip.
+fn set_fullscreen(state: &mut CompState, id: WindowId, fullscreen: bool) {
+ let already = state.wm.borrow().is_fullscreen(id);
+ if already != fullscreen {
+ state.wm.borrow_mut().toggle_fullscreen(id);
+ state.redraw_decoration_buffer(id);
+ state.sync_geometry(id);
+ }
+ send_state(state, id);
+}
+
+/// Creates and sends a new handle for `id` to `manager`, with its full
+/// initial state (title, app_id, activated/maximized/minimized, done) --
+/// the sequence `new_toplevel`'s own doc comment requires: "all initial
+/// details... will be sent immediately after this event".
+fn announce(state: &mut CompState, manager: &ZwlrForeignToplevelManagerV1, id: WindowId, client: &Client, dh: &DisplayHandle) {
+ let Ok(handle) = client.create_resource::(dh, manager.version(), ToplevelHandleData { window: id }) else {
+ return;
+ };
+ manager.toplevel(&handle);
+ state.foreign_toplevel_handles.entry(id).or_default().push(handle.clone());
+ send_state_to(state, id, &[handle]);
+}
+
+/// Re-sends title/app_id/state/done to every handle a window currently has
+/// - one per bound manager. Used both right after `announce` creates a
+/// fresh handle and whenever state actually changes (`set_maximized`,
+/// `update_activated`).
+pub(crate) fn send_state(state: &mut CompState, id: WindowId) {
+ let Some(handles) = state.foreign_toplevel_handles.get(&id).cloned() else { return };
+ send_state_to(state, id, &handles);
+}
+
+fn send_state_to(state: &mut CompState, id: WindowId, handles: &[ZwlrForeignToplevelHandleV1]) {
+ let Some(w) = state.wm.borrow().window(id).cloned() else { return };
+ let focused = state.wm.borrow().focused_id() == Some(id);
+ let fullscreen = state.wm.borrow().is_fullscreen(id);
+ let bytes = state_flags(w.maximized, w.minimized, focused, fullscreen);
+
+ for handle in handles {
+ handle.title(w.title.clone());
+ handle.app_id(w.app_id.clone());
+ handle.state(bytes.clone());
+ handle.done();
+ }
+}
+
+/// Packs the flags a window currently has into the wire format
+/// `zwlr_foreign_toplevel_handle_v1.state` expects: the raw bytes of
+/// however many native-endian u32 enum values apply, back to back. Pulled
+/// out of `send_state_to` as the one piece of this module that's pure
+/// logic rather than protocol I/O, so it's unit-testable the way
+/// `decoration.rs`'s rasterizers are.
+fn state_flags(maximized: bool, minimized: bool, activated: bool, fullscreen: bool) -> Vec {
+ let mut flags = Vec::new();
+ if maximized {
+ flags.push(State::Maximized as u32);
+ }
+ if minimized {
+ flags.push(State::Minimized as u32);
+ }
+ if activated {
+ flags.push(State::Activated as u32);
+ }
+ if fullscreen {
+ flags.push(State::Fullscreen as u32);
+ }
+ let mut bytes = Vec::with_capacity(flags.len() * 4);
+ for f in flags {
+ bytes.extend_from_slice(&f.to_ne_bytes());
+ }
+ bytes
+}
+
+/// Called from `new_managed_window`/`finish_x11_window_setup`. Announces
+/// the new window to every currently-bound manager - `GlobalDispatch::bind`
+/// above only covers managers that bind *before* a window exists; this is
+/// the other half, for ones that were already bound.
+pub(crate) fn window_created(state: &mut CompState, id: WindowId) {
+ let managers = state.foreign_toplevel_managers.clone();
+ for manager in &managers {
+ let Some(client) = manager.client() else { continue };
+ let dh = state.dh.clone();
+ announce(state, manager, id, &client, &dh);
+ }
+}
+
+/// Called from the native/X11 `remove_window` paths. Every real Wayland
+/// compositor's foreign-toplevel implementation must send `closed` before
+/// a handle becomes unusable - skipping it would leave a dock's entry for
+/// this window dangling with no signal to ever remove it.
+pub(crate) fn window_closed(state: &mut CompState, id: WindowId) {
+ if let Some(handles) = state.foreign_toplevel_handles.remove(&id) {
+ for handle in handles {
+ handle.closed();
+ }
+ }
+}
+
+/// Called from `set_keyboard_focus`, the single chokepoint every focus
+/// change already goes through - re-announces state for whichever window
+/// lost `Activated` and whichever gained it, so a dock's focused-app
+/// highlight tracks real focus instead of only ever reflecting whatever was
+/// focused at creation time.
+///
+/// Only `Activated` is kept live this way - maximized/minimized/fullscreen
+/// changes are re-broadcast from their own call sites instead (the pointer-
+/// driven titlebar handlers in `input.rs`, and the client-request handlers
+/// in `protocols.rs`/`xwayland.rs`, all call `send_state` directly). The one
+/// trigger that still doesn't re-broadcast is a *compositor keybinding*
+/// (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`): `crates/config`
+/// only holds a `WindowManager` reference, not `CompState`, so it has no way
+/// to reach this module. See `docs/PANEL_SUPPORT_TODO.md`'s P1 section.
+pub(crate) fn update_activated(state: &mut CompState, old: Option, new: Option<&WlSurface>) {
+ let old_id = old.as_ref().and_then(|s| window_id_for_surface(state, s));
+ let new_id = new.and_then(|s| window_id_for_surface(state, s));
+ if old_id == new_id {
+ return;
+ }
+ if let Some(id) = old_id {
+ send_state(state, id);
+ }
+ if let Some(id) = new_id {
+ send_state(state, id);
+ }
+}
+
+fn window_id_for_surface(state: &CompState, surface: &WlSurface) -> Option {
+ state.surface_to_id.get(surface).copied()
+}
+
+/// Diff-broadcasts maximized/minimized/fullscreen against what was last
+/// sent for each window, called once per frame from `CompState::
+/// tick_dirty_broadcasts` (same poll-loop cadence as `tick_animations`).
+///
+/// Every *known* trigger already re-broadcasts immediately at its own call
+/// site: this module's own requests (`set_maximized`/`set_minimized`/
+/// `set_fullscreen`), the pointer-driven titlebar handlers in `input.rs`,
+/// and a client's own `xdg_toplevel`/X11 request in `protocols.rs`/
+/// `xwayland.rs`. The one trigger documented as a real gap in
+/// `docs/PANEL_SUPPORT_TODO.md` is a *compositor keybinding*
+/// (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`): `crates/config`
+/// is the platform-agnostic scripting engine, shared with the X11/macOS
+/// backends, and only ever holds a `WindowManager` reference - it cannot
+/// reach this Wayland-protocol-specific module directly, so state it
+/// changes went stale in every dock/panel until *something else*
+/// coincidentally re-broadcast that window. Rather than thread a callback
+/// through `WindowManager` for every future call site that might change
+/// this state (and inevitably miss the next one), this diffs against
+/// `WindowManager`'s live state once a frame and catches all of them
+/// uniformly, including ones that don't exist yet.
+///
+/// One accepted redundancy: a brand-new window has no prior entry in
+/// `last_broadcast_flags`, so its first tick after creation sends one
+/// extra (identical) `send_state` on top of the one `window_created`
+/// already sent - harmless per-window, one-time, and far simpler than
+/// seeding the cache at creation just to suppress it.
+pub(crate) fn broadcast_dirty_state(state: &mut CompState) {
+ let current: Vec<(WindowId, bool, bool, bool)> =
+ state.wm.borrow().windows().map(|w| (w.id, w.maximized, w.minimized, w.fullscreen)).collect();
+ let mut changed = Vec::new();
+ for (id, maximized, minimized, fullscreen) in ¤t {
+ let key = (*maximized, *minimized, *fullscreen);
+ if state.last_broadcast_flags.get(id) != Some(&key) {
+ state.last_broadcast_flags.insert(*id, key);
+ changed.push(*id);
+ }
+ }
+ // Drops entries for windows that no longer exist - `window_closed`
+ // already tells clients the handle itself is gone; this just stops the
+ // map growing forever across the life of a session.
+ let live: std::collections::HashSet = current.iter().map(|(id, ..)| *id).collect();
+ state.last_broadcast_flags.retain(|id, _| live.contains(id));
+ for id in changed {
+ send_state(state, id);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn as_u32s(bytes: &[u8]) -> Vec {
+ bytes.chunks_exact(4).map(|c| u32::from_ne_bytes(c.try_into().unwrap())).collect()
+ }
+
+ #[test]
+ fn no_flags_is_an_empty_array() {
+ assert_eq!(state_flags(false, false, false, false), Vec::::new());
+ }
+
+ #[test]
+ fn each_flag_packs_its_own_enum_value() {
+ assert_eq!(as_u32s(&state_flags(true, false, false, false)), vec![State::Maximized as u32]);
+ assert_eq!(as_u32s(&state_flags(false, true, false, false)), vec![State::Minimized as u32]);
+ assert_eq!(as_u32s(&state_flags(false, false, true, false)), vec![State::Activated as u32]);
+ assert_eq!(as_u32s(&state_flags(false, false, false, true)), vec![State::Fullscreen as u32]);
+ }
+
+ #[test]
+ fn all_flags_combine_in_one_array() {
+ let values = as_u32s(&state_flags(true, true, true, true));
+ assert_eq!(values.len(), 4);
+ assert!(values.contains(&(State::Maximized as u32)));
+ assert!(values.contains(&(State::Minimized as u32)));
+ assert!(values.contains(&(State::Activated as u32)));
+ assert!(values.contains(&(State::Fullscreen as u32)));
+ }
+}
diff --git a/crates/wayland/src/gamma_control.rs b/crates/wayland/src/gamma_control.rs
new file mode 100644
index 0000000..d126f26
--- /dev/null
+++ b/crates/wayland/src/gamma_control.rs
@@ -0,0 +1,90 @@
+//! `zwlr_gamma_control_manager_v1`: lets a client (a night-light daemon like
+//! `gammastep`/`wlsunset`, or a settings panel) set a per-output gamma
+//! ramp - the mechanism behind "reduce blue light in the evening" features.
+//!
+//! DRM/udev backend only: there is no real CRTC gamma table to adjust when
+//! nested under a host compositor, same reasoning as `output_power.rs`. The
+//! global is genuinely not created there (`CompState::_gamma_control_state`
+//! is `Option`, `None` for `winit.rs`) rather than advertised-and-always-
+//! failing.
+//!
+//! No smithay helper exists for this protocol, so the `GlobalDispatch`/
+//! `Dispatch` plumbing below is hand-written against the raw
+//! `wayland-protocols-wlr` server bindings, the same pattern as
+//! `screencopy.rs`/`output_power.rs`.
+
+use smithay::reexports::wayland_server::backend::GlobalId;
+use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
+use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New};
+use wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_manager_v1::{self, ZwlrGammaControlManagerV1};
+use wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_v1::{self, ZwlrGammaControlV1};
+
+use crate::state::CompState;
+
+/// The manager global. Held by `CompState` purely to keep the global alive
+/// for the compositor's lifetime.
+pub struct GammaControlManagerState {
+ _global: GlobalId,
+}
+
+impl GammaControlManagerState {
+ pub fn new(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch + 'static,
+ {
+ Self { _global: dh.create_global::(1, ()) }
+ }
+}
+
+/// Which `wl_output` a `zwlr_gamma_control_v1` object controls, resolved
+/// once at creation - same reasoning as `output_power::OutputPowerData`.
+pub struct GammaControlData {
+ output: WlOutput,
+}
+
+impl GlobalDispatch for CompState {
+ fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ data_init.init(manager, ());
+ }
+}
+
+impl Dispatch for CompState {
+ fn request(
+ state: &mut Self,
+ _client: &Client,
+ _manager: &ZwlrGammaControlManagerV1,
+ request: zwlr_gamma_control_manager_v1::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ use zwlr_gamma_control_manager_v1::Request;
+ let Request::GetGammaControl { id, output } = request else { return };
+ let control = data_init.init(id, GammaControlData { output: output.clone() });
+ // Required "sent immediately when the gamma control object is
+ // created" - `failed()` covers an output that doesn't resolve or
+ // has no gamma ramp at all (headless/virtual outputs), rather than
+ // sending a nonsensical size.
+ match state.gamma_ramp_size(&output) {
+ Some(size) => control.gamma_size(size),
+ None => control.failed(),
+ }
+ }
+}
+
+impl Dispatch for CompState {
+ fn request(
+ state: &mut Self,
+ _client: &Client,
+ resource: &ZwlrGammaControlV1,
+ request: zwlr_gamma_control_v1::Request,
+ data: &GammaControlData,
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, Self>,
+ ) {
+ let zwlr_gamma_control_v1::Request::SetGamma { fd } = request else { return };
+ if state.set_gamma_ramp(&data.output, fd).is_none() {
+ resource.failed();
+ }
+ }
+}
diff --git a/crates/wayland/src/gtk_shell.rs b/crates/wayland/src/gtk_shell.rs
new file mode 100644
index 0000000..f4ae030
--- /dev/null
+++ b/crates/wayland/src/gtk_shell.rs
@@ -0,0 +1,116 @@
+//! `gtk_shell1`/`gtk_surface1`: the Wayland-native half of global-menu
+//! support - see `srdwm_core::GlobalMenu`'s doc comment and `xwayland.rs`'s
+//! `EwmhState::read_global_menu` for the XWayland half and the full
+//! rationale (carry the D-Bus *address*, never the menu content itself).
+//!
+//! GTK4 only calls `gtk_surface1.set_dbus_properties` - the one request
+//! this module actually needs - once it knows the compositor supports the
+//! protocol at all, which it establishes by binding `gtk_shell1` and
+//! getting a `capabilities` event back. Everything else in the protocol
+//! (startup-notification IDs, tiled-state events, modal hints, the
+//! titlebar-gesture request GNOME Shell uses for its own double/right/
+//! middle-click-titlebar handling) is real but out of scope for what this
+//! pass is actually for - acknowledged with a no-op rather than silently
+//! ignored, so a future pass extending this has a clear list of what was
+//! deliberately left alone.
+
+use smithay::reexports::wayland_server::backend::GlobalId;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New};
+
+use crate::gtk_shell_protocol::server::gtk_shell1::{self, GtkShell1};
+use crate::gtk_shell_protocol::server::gtk_surface1::{self, GtkSurface1};
+use crate::state::CompState;
+
+const PROTOCOL_VERSION: u32 = 7;
+
+/// `global_app_menu | global_menu_bar` - both bits set unconditionally.
+/// srdwm has no reason to advertise one without the other: both map onto
+/// the same `GlobalMenu`, just the app-menu-only fallback for a client
+/// that never got around to exporting a real menu bar.
+const CAPABILITIES: u32 = 0b011;
+
+pub struct GtkShellState {
+ _global: GlobalId,
+}
+
+impl GtkShellState {
+ pub fn new(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch + 'static,
+ {
+ Self { _global: dh.create_global::(PROTOCOL_VERSION, ()) }
+ }
+}
+
+/// The `wl_surface` a `gtk_surface1` was created for - the only thing
+/// `set_dbus_properties` needs to know which `WindowId` to attach its
+/// payload to.
+pub struct GtkSurfaceData {
+ surface: WlSurface,
+}
+
+impl GlobalDispatch for CompState {
+ fn bind(_state: &mut CompState, _dh: &DisplayHandle, _client: &Client, resource: New, _global_data: &(), data_init: &mut DataInit<'_, CompState>) {
+ let shell = data_init.init(resource, ());
+ // Sent once, right after bind - see this module's doc comment for
+ // why this has to happen unprompted rather than waiting to be
+ // asked: GTK only bothers calling `set_dbus_properties` later if it
+ // saw this first.
+ shell.capabilities(CAPABILITIES);
+ }
+}
+
+impl Dispatch for CompState {
+ fn request(state: &mut CompState, _client: &Client, _resource: &GtkShell1, request: gtk_shell1::Request, _data: &(), _dh: &DisplayHandle, data_init: &mut DataInit<'_, CompState>) {
+ if let gtk_shell1::Request::GetGtkSurface { gtk_surface, surface } = request {
+ data_init.init(gtk_surface, GtkSurfaceData { surface });
+ }
+ // `set_startup_id`/`system_bell`/`notify_launch`: no startup-
+ // notification or accessibility-bell feature exists to wire these
+ // into yet - see this module's doc comment.
+ let _ = state;
+ }
+}
+
+impl Dispatch for CompState {
+ fn request(state: &mut CompState, _client: &Client, _resource: &GtkSurface1, request: gtk_surface1::Request, data: &GtkSurfaceData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, CompState>) {
+ let gtk_surface1::Request::SetDbusProperties { menubar_path, app_menu_path, window_object_path, application_object_path, unique_bus_name, .. } = request else {
+ // `set_modal`/`unset_modal`/`present`/`request_focus`/`release`/
+ // `titlebar_gesture`/`set_a11y_properties`: real requests, none
+ // of which this pass has a feature behind yet.
+ return;
+ };
+ let Some(&id) = state.surface_to_id.get(&data.surface) else { return };
+ // A `None`/empty bus name means the client is *clearing* its menu
+ // (or never had one) - matches `xwayland.rs`'s `read_global_menu`
+ // returning `None` for the same case, so a panel sees the same
+ // shape regardless of which backend a window came from.
+ // Always `MenuSource::Gtk`: this protocol has no Unity-style
+ // equivalent to carry, unlike XWayland's `_UNITY_OBJECT_PATH` --
+ // see `xwayland.rs`'s `read_global_menu` for the case that needs
+ // the other variant.
+ let menu = unique_bus_name.filter(|s| !s.is_empty()).map(|bus_name| srdwm_core::GlobalMenu {
+ bus_name,
+ menu_path: menubar_path.filter(|s| !s.is_empty()).or_else(|| app_menu_path.filter(|s| !s.is_empty())),
+ app_path: application_object_path.filter(|s| !s.is_empty()),
+ window_path: window_object_path.filter(|s| !s.is_empty()),
+ source: srdwm_core::MenuSource::Gtk,
+ });
+ if let Some(w) = state.wm.borrow_mut().window_mut(id) {
+ w.global_menu = menu;
+ }
+ }
+
+ fn destroyed(state: &mut CompState, _client: smithay::reexports::wayland_server::backend::ClientId, _resource: &GtkSurface1, data: &GtkSurfaceData) {
+ // The surface itself may already be gone (this fires on the normal
+ // window-close teardown path too, not just an explicit `release`)
+ // - `surface_to_id` simply won't resolve in that case, same as
+ // any other post-close lookup elsewhere in this codebase.
+ if let Some(&id) = state.surface_to_id.get(&data.surface) {
+ if let Some(w) = state.wm.borrow_mut().window_mut(id) {
+ w.global_menu = None;
+ }
+ }
+ }
+}
diff --git a/crates/wayland/src/gtk_shell_protocol.rs b/crates/wayland/src/gtk_shell_protocol.rs
new file mode 100644
index 0000000..5582680
--- /dev/null
+++ b/crates/wayland/src/gtk_shell_protocol.rs
@@ -0,0 +1,29 @@
+//! Compile-time-generated wire bindings for `gtk_shell1`/`gtk_surface1`
+//! (GTK's own private protocol - `protocols/gtk-shell.xml`, vendored from
+//! `gdk/wayland/protocol/gtk-shell.xml` in GTK's own source tree). Unlike
+//! the wlr/staging protocols this codebase already uses, there is no
+//! published `wayland-protocols-*` crate for this one - it isn't part of
+//! any standardised protocol umbrella, just GTK's own extension - so it's
+//! generated here the same way `wayland-protocols-wlr` generates its own
+//! bindings internally (see that crate's `protocol_macro.rs`), just
+//! server-side only, since this compositor never needs the client half.
+//!
+//! `gtk_shell.rs` is the actual `GlobalDispatch`/`Dispatch` handler logic;
+//! this module is only the raw generated types it's built against.
+
+#![allow(dead_code, non_camel_case_types, unused_unsafe, unused_variables)]
+#![allow(non_upper_case_globals, non_snake_case, unused_imports)]
+#![allow(missing_docs, clippy::all)]
+
+pub mod server {
+ use smithay::reexports::wayland_server;
+ use smithay::reexports::wayland_server::protocol::*;
+
+ pub mod __interfaces {
+ use smithay::reexports::wayland_server::protocol::__interfaces::*;
+ wayland_scanner::generate_interfaces!("protocols/gtk-shell.xml");
+ }
+ use self::__interfaces::*;
+
+ wayland_scanner::generate_server_code!("protocols/gtk-shell.xml");
+}
diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs
index 17e94d4..264c986 100644
--- a/crates/wayland/src/input.rs
+++ b/crates/wayland/src/input.rs
@@ -17,6 +17,7 @@ use smithay::input::pointer::{ButtonEvent, MotionEvent};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, SERIAL_COUNTER};
use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, Layer};
+use std::time::{Duration, Instant};
use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId};
@@ -41,12 +42,28 @@ pub(crate) fn last_pointer_pos(state: &CompState) -> Point {
/// `pos` is in the global space; layer geometry is relative to its own
/// output, so the pointer is translated into output-local coordinates
/// before hit-testing and the result translated back out.
-pub(crate) fn layer_surface_under(state: &CompState, pos: Point) -> Option<(WlSurface, Point)> {
+/// Only checked for `Overlay`/`Top` before a window hit-test, and again for
+/// `Bottom`/`Background` after one comes up empty - see the two call
+/// sites in `handle_pointer_button`/`handle_pointer_position` for why it's
+/// split rather than one four-layer loop here. A `Bottom`/`Background`
+/// surface (a desktop-icons layer, a wallpaper daemon that wants clicks) is
+/// meant to sit *behind* normal windows, so a window covering that point
+/// should still get the click; `Overlay`/`Top` (an on-screen keyboard, a
+/// bar, a dock) are meant to sit in front of everything, windows included.
+///
+/// Was `Overlay`/`Top` only, full stop - a `Bottom`-layer surface was
+/// silently unclickable no matter what, since nothing else in
+/// `handle_pointer_button` ever checked layers at all. Not the cause of
+/// the live "clicking the dock does nothing" report (confirmed: that dock
+/// uses `Layer::Top`, which was already checked), but a real, separate gap
+/// found while chasing it - worth closing regardless of whether anything
+/// currently deployed sits at `Bottom`/`Background` yet.
+pub(crate) fn layer_surface_under_layers(state: &CompState, pos: Point, layers: [Layer; 2]) -> Option<(WlSurface, Point)> {
let entry = state.output_at(pos)?;
let origin = entry.location;
let local = pos - origin.to_f64();
let map = layer_map_for_output(&entry.output);
- for layer_kind in [Layer::Overlay, Layer::Top] {
+ for layer_kind in layers {
let Some(layer) = map.layer_under(layer_kind, local) else { continue };
let Some(geo) = map.layer_geometry(layer) else { continue };
if let Some((surface, surface_loc)) = layer.surface_under(local - geo.loc.to_f64(), WindowSurfaceType::ALL) {
@@ -56,7 +73,49 @@ pub(crate) fn layer_surface_under(state: &CompState, pos: Point) -
None
}
+pub(crate) fn layer_surface_under(state: &CompState, pos: Point) -> Option<(WlSurface, Point)> {
+ layer_surface_under_layers(state, pos, [Layer::Overlay, Layer::Top])
+}
+
+/// The `Bottom`/`Background` half of the same lookup - see
+/// `layer_surface_under_layers`'s doc comment for the ordering rationale.
+pub(crate) fn background_layer_surface_under(state: &CompState, pos: Point) -> Option<(WlSurface, Point)> {
+ layer_surface_under_layers(state, pos, [Layer::Bottom, Layer::Background])
+}
+
+/// `ext_idle_notify_v1`'s whole job is answering "has the user touched an
+/// input device recently" - `IdleNotifierState` does the actual timer
+/// bookkeeping (see its own doc comment), this just has to be called from
+/// every real input path, deliberately including while the session is
+/// locked: idle activity is about the seat, not about which surface (if
+/// any) an event ends up delivered to, and a lock daemon watching this
+/// protocol to decide when to re-dim/re-lock still needs to see real
+/// movement even though nothing else happens with it at a locked screen.
+///
+/// Throttled to once per 250ms: `notify_activity` removes and re-inserts a
+/// calloop timer for every live notification, every call, with no
+/// throttling of its own - fine at keypress/click frequency, but pointer
+/// motion can fire far more often than that during a drag, and idle
+/// timeouts are measured in minutes, not milliseconds, so nothing about
+/// idle detection needs - or can even perceive - finer resolution than
+/// this. The same class of hot-path-on-every-motion-event cost that made
+/// this session's earlier diagnostic logging a real, measured regression
+/// (see `docs/IMPLEMENTATION_STATUS.md`), just cheap enough here (in-memory
+/// bookkeeping, not synchronous I/O) that throttling rather than removing
+/// it outright is the right amount of caution.
+fn notify_idle_activity(state: &mut CompState) {
+ const THROTTLE: Duration = Duration::from_millis(250);
+ let now = Instant::now();
+ if state.last_idle_notify.is_some_and(|last| now.duration_since(last) < THROTTLE) {
+ return;
+ }
+ state.last_idle_notify = Some(now);
+ let seat = state.seat.clone();
+ state.idle_notifier_state.notify_activity(&seat);
+}
+
pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point, 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 {
@@ -64,13 +123,16 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point, over_layer_surface: bool, over_content: bool) {
+ use smithay::input::pointer::{CursorIcon, CursorImageStatus};
+
+ if over_layer_surface {
+ return;
+ }
+ let edge = match hit {
+ Some((_, TitlebarHit::Resize(edge))) => Some(edge),
+ _ => state.wm.borrow().resize_edge(),
+ };
+ let icon = match edge {
+ Some(edge) => resize_cursor_icon(edge),
+ // Hovering our own decoration but not an edge (the drag area, a
+ // button) and not actively resizing: back to the plain arrow.
+ None if hit.is_some() => CursorIcon::Default,
+ // Over a client's own content: leave `cursor_status` alone, per the
+ // doc comment above - the client drives it.
+ None if over_content => return,
+ // Bare desktop: nothing else will ever reset this, so we have to.
+ None => CursorIcon::Default,
+ };
+ state.cursor_status = CursorImageStatus::Named(icon);
+}
+
+fn resize_cursor_icon(edge: srdwm_core::ResizeEdge) -> smithay::input::pointer::CursorIcon {
+ use smithay::input::pointer::CursorIcon;
+ use srdwm_core::ResizeEdge;
+ match edge {
+ ResizeEdge::Left | ResizeEdge::Right => CursorIcon::EwResize,
+ ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::NsResize,
+ ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize,
+ ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize,
+ }
+}
+
/// The underlying `wl_surface` for a mapped window, regardless of whether
/// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` --
/// `desktop::Window` exposes these as two separate accessors with no
@@ -137,9 +298,33 @@ pub(crate) fn focus_window(state: &mut CompState, id: WindowId) {
state.set_keyboard_focus(surface);
}
+/// Re-syncs real Wayland/X11 keyboard focus to whatever `WindowManager`
+/// already considers focused, without changing what that is.
+///
+/// For callers where core's own focus already moved on its own --
+/// specifically `WindowManager::remove_window`'s fallback to
+/// `self.order.last()` when the just-closed window was the focused one --
+/// and only the Wayland/X11 side needs to catch up to it. Without this, the
+/// window core now considers focused (and renders as such) never actually
+/// receives a keystroke until it's clicked, since nothing told
+/// `set_keyboard_focus` focus had moved.
+///
+/// `focus_window` above is for the opposite direction: driving core's
+/// focus deliberately (a click, a keybinding) and syncing outward from
+/// that. This is "core already decided, catch the rest of the compositor
+/// up" - `wm.focus_window` must not be called again here, since the id
+/// core picked (or `None`, if nothing is left) is exactly what should win.
+pub(crate) fn sync_keyboard_focus(state: &mut CompState) {
+ let focused = state.wm.borrow().focused_id();
+ let surface = focused.and_then(|id| state.id_to_window.get(&id)).and_then(dwindow_wl_surface);
+ state.set_keyboard_focus(surface);
+}
+
pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point, button: u32, pressed: bool, time: u32) {
+ notify_idle_activity(state);
const BTN_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111;
+ const BTN_MIDDLE: u32 = 0x112;
let serial = SERIAL_COUNTER.next_serial();
// Locked: forward the click to the lock surface (it may have a button or
@@ -148,10 +333,31 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point {
state.wm.borrow_mut().toggle_maximize(id);
state.sync_geometry(id);
+ crate::foreign_toplevel::send_state(state, id);
+ }
+ TitlebarHit::Minimize => {
+ state.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(state, id);
}
- TitlebarHit::Minimize => state.wm.borrow_mut().minimize_window(id),
TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32),
}
} else if layer_hit.is_none() {
@@ -240,18 +451,73 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point>(state: &mut CompState, event: &E) {
+ notify_idle_activity(state);
let keycode = event.key_code();
let key_state = event.state();
let time = event.time_msec();
@@ -340,6 +607,7 @@ where
{
use smithay::backend::input::Axis;
+ notify_idle_activity(state);
if state.lock.locked {
return false;
}
@@ -358,5 +626,20 @@ where
// 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() };
wm.switch_workspace(ids[next]);
+ drop(wm);
+ // Without this, the switch above is invisible: nothing shows or hides
+ // a single window for the new workspace until `main.rs`'s `sync()`
+ // runs, which only happens when a polled event sets `dirty` - see
+ // `srdwm_core::Event::WorkspaceChanged`'s doc comment. Found live-
+ // testing the unrelated `ext_workspace_v1` protocol's own `activate`
+ // request, which has the identical problem; this 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
+ // active workspace instead.
+ crate::workspace::broadcast_active_workspace(state);
true
}
diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs
index 1d04d01..e8a50ef 100644
--- a/crates/wayland/src/lib.rs
+++ b/crates/wayland/src/lib.rs
@@ -36,16 +36,25 @@
//! - xdg-decoration is forced to server-side mode (`Mode::ServerSide`) so
//! well-behaved clients don't also draw their own client-side titlebar.
+mod context_menu;
mod cursor;
mod decoration;
mod elements;
+mod foreign_toplevel;
+mod gamma_control;
+mod gtk_shell;
+mod gtk_shell_protocol;
mod input;
mod lock;
+mod output_management;
+mod output_power;
mod protocols;
mod screencopy;
mod state;
mod udev;
mod winit;
+mod workspace;
+mod xkb_config;
mod xwayland;
use std::cell::RefCell;
diff --git a/crates/wayland/src/lock.rs b/crates/wayland/src/lock.rs
index 51b6158..ccdea98 100644
--- a/crates/wayland/src/lock.rs
+++ b/crates/wayland/src/lock.rs
@@ -112,6 +112,19 @@ impl SessionLockHandler for CompState {
self.lock.surfaces.clear();
self.lock.presented.clear();
self.lock.pending_confirm = None;
+ // udev backend only: the lock scene was rendered through the same
+ // per-head damage tracker as the normal desktop (see
+ // `render_udev_frame`), always with a forced full redraw, so its
+ // element-state history now reflects the lock surface rather than
+ // whatever was on screen before locking. Reset each head's buffer
+ // ages so the next normal-scene render is a full redraw too,
+ // instead of asking the tracker to diff the desktop against a
+ // now-irrelevant lock-scene history.
+ if let Some(udev) = self.udev.as_mut() {
+ for head in &mut udev.heads {
+ head.ages = [0, 0];
+ }
+ }
// Hand focus back to whatever srdwm considers the focused window.
let surface = self
.wm
diff --git a/crates/wayland/src/output_management.rs b/crates/wayland/src/output_management.rs
new file mode 100644
index 0000000..4697a40
--- /dev/null
+++ b/crates/wayland/src/output_management.rs
@@ -0,0 +1,559 @@
+//! `zwlr_output_management_v1`: lets a settings panel enumerate every
+//! output srdwm drives - name, description, physical size, supported
+//! modes, and current position/scale/transform/enabled state - and
+//! request changes. smithay 0.7 has no built-in helper for this protocol;
+//! hand-written against `wayland-protocols-wlr`'s raw server bindings,
+//! same pattern as `foreign_toplevel.rs`/`workspace.rs`. Requested by the
+//! field-survey comparison against sway/Hyprland/river (`docs/
+//! IMPLEMENTATION_STATUS.md`): every one of them treats this as core, not
+//! optional, once external monitors, scaling, or a settings panel are in
+//! the picture.
+//!
+//! **Deliberately conservative on `apply`/`test`**: a configuration can
+//! change a head's position, scale and transform - all straightforward
+//! via `Output::change_current_state`, which already exists and is
+//! already exercised (hotplug, the nested backend's own resize handling).
+//! Two things a real configuration payload can also ask for are
+//! deliberately *not* attempted, and fail honestly (`failed`, not a
+//! silent no-op) rather than accept a request this compositor can't
+//! actually carry out:
+//!
+//! - **Disabling/enabling a head.** srdwm has no concept of a head that
+//! exists but isn't mapped into the global compositor space - every
+//! output it advertises is always enabled. A request to actually
+//! change that state (not just re-`enable_head` an already-enabled one,
+//! which every real settings panel does on every apply regardless of
+//! what actually changed) fails.
+//! - **Switching resolution or refresh rate.** Real DRM mode-setting means
+//! finding the matching connector mode and reprogramming the CRTC --
+//! substantial, hardware-dependent work this pass didn't attempt, and
+//! this development environment has no multi-mode hardware to verify it
+//! against even if it had. A `set_mode`/`set_custom_mode` that matches
+//! the head's *already-current* mode (the common case: a panel echoes
+//! back existing state alongside a real position/scale change) is
+//! accepted as a no-op; a genuinely different one fails.
+//!
+//! Both are real, scoped-out gaps, not oversights - worth closing in a
+//! later pass once there's hardware to verify a mode-setting change
+//! against.
+
+
+use std::collections::{HashMap, HashSet};
+use std::sync::{Arc, Mutex};
+
+use smithay::output::{Mode as OutputMode, Output, Scale};
+use smithay::reexports::wayland_server::backend::{ClientId, GlobalId};
+use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, WEnum};
+use smithay::utils::{Point, Transform};
+
+use wayland_protocols_wlr::output_management::v1::server::zwlr_output_configuration_head_v1::{self, ZwlrOutputConfigurationHeadV1};
+use wayland_protocols_wlr::output_management::v1::server::zwlr_output_configuration_v1::{self, ZwlrOutputConfigurationV1};
+use wayland_protocols_wlr::output_management::v1::server::zwlr_output_head_v1::{self, ZwlrOutputHeadV1};
+use wayland_protocols_wlr::output_management::v1::server::zwlr_output_manager_v1::{self, ZwlrOutputManagerV1};
+use wayland_protocols_wlr::output_management::v1::server::zwlr_output_mode_v1::{self, ZwlrOutputModeV1};
+
+use crate::state::CompState;
+
+const PROTOCOL_VERSION: u32 = 4;
+
+pub struct OutputManagementState {
+ _global: GlobalId,
+}
+
+impl OutputManagementState {
+ pub fn new(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch + 'static,
+ {
+ Self { _global: dh.create_global::(PROTOCOL_VERSION, ()) }
+ }
+}
+
+pub struct HeadData {
+ name: String,
+}
+
+#[derive(Clone, Copy, PartialEq)]
+pub struct ModeData {
+ mode: OutputMode,
+}
+
+/// What a client has asked for one head within a single in-flight
+/// `zwlr_output_configuration_v1` - built up by `enable_head`/
+/// `disable_head` and the requests on the `zwlr_output_configuration_head_v1`
+/// object that creates, then read back all at once by `apply`/`test`.
+enum HeadRequest {
+ Disabled,
+ Enabled { position: Option<(i32, i32)>, scale: Option, transform: Option, mode: Option },
+}
+
+enum ModeRequest {
+ Existing(OutputMode),
+ Custom { width: i32, height: i32, refresh: i32 },
+}
+
+/// Shared between a `ZwlrOutputConfigurationV1` and every
+/// `ZwlrOutputConfigurationHeadV1` it creates, keyed by output name (the
+/// one stable identity a head has - see `HeadData`'s doc comment on why
+/// `Output` itself isn't used as the key).
+type ConfigMap = Arc>>;
+
+pub struct ConfigurationData {
+ serial: u32,
+ heads: ConfigMap,
+}
+
+pub struct ConfigurationHeadData {
+ head_name: String,
+ heads: ConfigMap,
+}
+
+impl GlobalDispatch for CompState {
+ fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ let manager = data_init.init(manager, ());
+ let outputs: Vec