srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland
diff options
context:
space:
mode:
Diffstat (limited to 'crates/wayland')
-rw-r--r--crates/wayland/Cargo.toml18
-rw-r--r--crates/wayland/protocols/gtk-shell.xml116
-rw-r--r--crates/wayland/src/context_menu.rs133
-rw-r--r--crates/wayland/src/cursor.rs473
-rw-r--r--crates/wayland/src/decoration.rs437
-rw-r--r--crates/wayland/src/elements.rs229
-rw-r--r--crates/wayland/src/foreign_toplevel.rs379
-rw-r--r--crates/wayland/src/gamma_control.rs90
-rw-r--r--crates/wayland/src/gtk_shell.rs116
-rw-r--r--crates/wayland/src/gtk_shell_protocol.rs29
-rw-r--r--crates/wayland/src/input.rs299
-rw-r--r--crates/wayland/src/lib.rs9
-rw-r--r--crates/wayland/src/lock.rs13
-rw-r--r--crates/wayland/src/output_management.rs559
-rw-r--r--crates/wayland/src/output_power.rs99
-rw-r--r--crates/wayland/src/protocols.rs532
-rw-r--r--crates/wayland/src/screencopy.rs35
-rw-r--r--crates/wayland/src/state.rs840
-rw-r--r--crates/wayland/src/udev.rs640
-rw-r--r--crates/wayland/src/winit.rs419
-rw-r--r--crates/wayland/src/workspace.rs251
-rw-r--r--crates/wayland/src/xkb_config.rs102
-rw-r--r--crates/wayland/src/xwayland.rs380
23 files changed, 6016 insertions, 182 deletions
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 @@
+<protocol name="gtk">
+
+ <interface name="gtk_shell1" version="7">
+ <description summary="gtk specific extensions">
+ gtk_shell is a protocol extension providing additional features for
+ clients implementing it.
+ </description>
+
+ <enum name="capability">
+ <entry name="global_app_menu" value="1"/>
+ <entry name="global_menu_bar" value="2"/>
+ <entry name="desktop_icons" value="3"/>
+ </enum>
+
+ <event name="capabilities">
+ <arg name="capabilities" type="uint"/>
+ </event>
+
+ <request name="get_gtk_surface">
+ <arg name="gtk_surface" type="new_id" interface="gtk_surface1"/>
+ <arg name="surface" type="object" interface="wl_surface"/>
+ </request>
+
+ <request name="set_startup_id">
+ <arg name="startup_id" type="string" allow-null="true"/>
+ </request>
+
+ <request name="system_bell">
+ <arg name="surface" type="object" interface="gtk_surface1" allow-null="true"/>
+ </request>
+
+ <!-- Version 3 additions -->
+ <request name="notify_launch" since="3">
+ <arg name="startup_id" type="string"/>
+ </request>
+
+ <!-- Version 6 does not add new API, but asserts that surface offsets are implemented -->
+ </interface>
+
+ <interface name="gtk_surface1" version="7">
+ <request name="set_dbus_properties">
+ <arg name="application_id" type="string" allow-null="true"/>
+ <arg name="app_menu_path" type="string" allow-null="true"/>
+ <arg name="menubar_path" type="string" allow-null="true"/>
+ <arg name="window_object_path" type="string" allow-null="true"/>
+ <arg name="application_object_path" type="string" allow-null="true"/>
+ <arg name="unique_bus_name" type="string" allow-null="true"/>
+ </request>
+
+ <request name="set_modal"/>
+ <request name="unset_modal"/>
+
+ <request name="present">
+ <arg name="time" type="uint"/>
+ </request>
+
+ <!-- Version 2 additions -->
+
+ <enum name="state">
+ <entry name="tiled" value="1"/>
+
+ <entry name="tiled_top" value="2" since="2" />
+ <entry name="tiled_right" value="3" since="2" />
+ <entry name="tiled_bottom" value="4" since="2" />
+ <entry name="tiled_left" value="5" since="2" />
+ </enum>
+
+ <enum name="edge_constraint" since="2">
+ <entry name="resizable_top" value="1"/>
+ <entry name="resizable_right" value="2"/>
+ <entry name="resizable_bottom" value="3"/>
+ <entry name="resizable_left" value="4"/>
+ </enum>
+
+ <event name="configure">
+ <arg name="states" type="array"/>
+ </event>
+
+ <event name="configure_edges" since="2">
+ <arg name="constraints" type="array"/>
+ </event>
+
+ <!-- Version 3 additions -->
+ <request name="request_focus" since="3">
+ <arg name="startup_id" type="string" allow-null="true"/>
+ </request>
+
+ <!-- Version 4 additions -->
+ <request name="release" type="destructor" since="4"/>
+
+ <!-- Version 5 additions -->
+ <enum name="gesture" since="5">
+ <entry name="double_click" value="1"/>
+ <entry name="right_click" value="2"/>
+ <entry name="middle_click" value="3"/>
+ </enum>
+
+ <enum name="error" since="5">
+ <entry name="invalid_gesture" value="0"/>
+ </enum>
+
+ <request name="titlebar_gesture" since="5">
+ <arg name="serial" type="uint"/>
+ <arg name="seat" type="object" interface="wl_seat"/>
+ <arg name="gesture" type="uint" enum="gesture"/>
+ </request>
+
+ <!-- Version 6 does not add new API, but asserts that surface offsets are implemented -->
+
+ <request name="set_a11y_properties" since="7">
+ <arg name="a11y_dbus_name" type="string" />
+ <arg name="toplevel_object_path" type="string" />
+ </request>
+ </interface>
+
+</protocol>
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<Self> {
+ 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<usize> {
+ 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<u8> {
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<u8> {
+ 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<u8> {
+ 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<u8> {
+ 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<u8> {
+ 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<u8> {
+ 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<u8> {
+ 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<u8>) -> 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<u8> {
/// over this output - otherwise every monitor would draw its own copy.
pub(crate) fn render_elements<R>(
status: &smithay::input::pointer::CursorImageStatus,
- buffer: &smithay::backend::renderer::element::memory::MemoryRenderBuffer,
+ buffers: &CursorBuffers,
renderer: &mut R,
pos: Point<f64, Logical>,
origin: Point<i32, Logical>,
@@ -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<usize> = 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<u8> {
+ 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<u8> {
+ let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
+ let bg = rgb_to_bgra(color, 255);
+ let mut buf = vec![0u8; width * thickness * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+ round_top_corners(&mut buf, width, thickness, CORNER_RADIUS + thickness as u32);
+ 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<u8> {
+///
+/// `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<u8> {
let (width, height) = (width.max(1) as usize, height.max(1) as usize);
let bg = rgb_to_bgra(background, 255);
let mut buf = vec![0u8; width * height * 4];
@@ -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)]
@@ -161,10 +417,56 @@ 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<R> where
@@ -17,4 +27,211 @@ smithay::backend::renderer::element::render_elements! {
Surface=WaylandSurfaceRenderElement<R>,
/// A bitmap srdwm rasterised: a titlebar, or the built-in cursor arrow.
Memory=MemoryRenderBufferRenderElement<R>,
+ /// 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<srdwm_core::Rect> {
+ 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<SolidColorBuffer>, 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<PopupTarget> {
+ 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::<Vec<_>>()
+ });
+ 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<R>(targets: &[PopupTarget], renderer: &mut R, origin: (i32, i32)) -> Vec<OverlayElement<R>>
+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<DWindow>,
+ damage: &'a [Rectangle<i32, Physical>],
+ scale: Scale<f64>,
+) -> impl Iterator<Item = &'a DWindow> + '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<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<ZwlrForeignToplevelManagerV1, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, ZwlrForeignToplevelManagerV1, _>(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<ZwlrForeignToplevelManagerV1, ()> for CompState {
+ fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ZwlrForeignToplevelManagerV1>, _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<WindowId> = 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<ZwlrForeignToplevelManagerV1, ()> 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<ZwlrForeignToplevelHandleV1, ToplevelHandleData> 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::<ZwlrForeignToplevelHandleV1, ToplevelHandleData, CompState>(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<u8> {
+ 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<WlSurface>, 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<WindowId> {
+ 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 &current {
+ 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<WindowId> = 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<u32> {
+ 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::<u8>::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<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<ZwlrGammaControlManagerV1, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, ZwlrGammaControlManagerV1, _>(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<ZwlrGammaControlManagerV1, ()> for CompState {
+ fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New<ZwlrGammaControlManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ data_init.init(manager, ());
+ }
+}
+
+impl Dispatch<ZwlrGammaControlManagerV1, ()> 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<ZwlrGammaControlV1, GammaControlData> 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<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<GtkShell1, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, GtkShell1, _>(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<GtkShell1, (), CompState> for CompState {
+ fn bind(_state: &mut CompState, _dh: &DisplayHandle, _client: &Client, resource: New<GtkShell1>, _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<GtkShell1, ()> 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<GtkSurface1, GtkSurfaceData> 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<f64, Logical> {
/// `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<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
+/// Only checked for `Overlay`/`Top` before a window hit-test, and again for
+/// `Bottom`/`Background` after one comes up empty - see the two call
+/// sites in `handle_pointer_button`/`handle_pointer_position` for why it's
+/// split rather than one four-layer loop here. A `Bottom`/`Background`
+/// surface (a desktop-icons layer, a wallpaper daemon that wants clicks) is
+/// meant to sit *behind* normal windows, so a window covering that point
+/// should still get the click; `Overlay`/`Top` (an on-screen keyboard, a
+/// bar, a dock) are meant to sit in front of everything, windows included.
+///
+/// Was `Overlay`/`Top` only, full stop - a `Bottom`-layer surface was
+/// silently unclickable no matter what, since nothing else in
+/// `handle_pointer_button` ever checked layers at all. Not the cause of
+/// the live "clicking the dock does nothing" report (confirmed: that dock
+/// uses `Layer::Top`, which was already checked), but a real, separate gap
+/// found while chasing it - worth closing regardless of whether anything
+/// currently deployed sits at `Bottom`/`Background` yet.
+pub(crate) fn layer_surface_under_layers(state: &CompState, pos: Point<f64, Logical>, layers: [Layer; 2]) -> Option<(WlSurface, Point<i32, Logical>)> {
let entry = state.output_at(pos)?;
let origin = entry.location;
let local = pos - origin.to_f64();
let map = layer_map_for_output(&entry.output);
- for layer_kind in [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<f64, Logical>) -
None
}
+pub(crate) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
+ layer_surface_under_layers(state, pos, [Layer::Overlay, Layer::Top])
+}
+
+/// The `Bottom`/`Background` half of the same lookup - see
+/// `layer_surface_under_layers`'s doc comment for the ordering rationale.
+pub(crate) fn background_layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
+ layer_surface_under_layers(state, pos, [Layer::Bottom, Layer::Background])
+}
+
+/// `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<f64, Logical>, time: u32) {
+ notify_idle_activity(state);
// Locked: pointer motion goes to the lock surface only. No hit-testing
// against windows/decorations, so no hover, no drag, no resize.
if state.lock.locked {
@@ -64,13 +123,16 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log
if let Some(pointer) = state.seat.get_pointer() {
let focus = surface.map(|s| (s, Point::from((0, 0)).to_f64()));
pointer.motion(state, focus, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
+ pointer.frame(state);
}
return;
}
let layer_hit = layer_surface_under(state, pos);
+ let over_layer_surface = layer_hit.is_some();
let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32);
let under = state.space.element_under(pos).map(|(w, loc)| (w.clone(), loc));
+ let over_content = under.is_some();
let Some(pointer) = state.seat.get_pointer() else { return };
if let Some((surface, loc)) = layer_hit {
@@ -80,13 +142,53 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log
// Over our own decoration - no client focus.
pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
} else if let Some((window, loc)) = under {
- if let Some(surface) = window.toplevel().map(|t| t.wl_surface().clone()) {
- let surface_loc = pos - loc.to_f64();
- pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time });
+ // `window.toplevel()` is only ever `Some` for a native xdg-shell
+ // surface - it's `None` for every XWayland window, and even for a
+ // plain xdg-shell one it's always the *root* surface regardless of
+ // which subsurface the pointer is actually over (video/GL overlays,
+ // some GTK/Electron popups). Either way that meant pointer focus
+ // landed on the wrong surface - or no surface at all, for X11
+ // clients - and the click coordinates were relative to the window
+ // root rather than whatever was actually under the cursor.
+ // `Window::surface_under` is smithay's own hit-test for this: it
+ // walks the real surface tree (subsurfaces and popups included) and
+ // unifies the xdg-shell/X11 cases the way `dwindow_wl_surface` does
+ // elsewhere in this module.
+ let win_relative = pos - loc.to_f64();
+ if let Some((surface, offset)) = window.surface_under(win_relative, WindowSurfaceType::ALL) {
+ let surface_loc = win_relative - offset.to_f64();
+ let surface_origin = (loc + offset).to_f64();
+ pointer.motion(state, Some((surface, surface_origin)), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time });
}
+ } else if let Some((surface, loc)) = background_layer_surface_under(state, pos) {
+ // Bare desktop, no window there either - last chance for a
+ // `Bottom`/`Background` layer surface (see
+ // `layer_surface_under_layers`'s doc comment) before giving up.
+ let surface_loc = pos - loc.to_f64();
+ pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time });
} else {
pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
}
+ // `PointerHandle::motion`/`button`/`axis` only queue the event with the
+ // active grab - nothing sends `wl_pointer.frame` on its own (confirmed
+ // reading smithay's `DefaultGrab`: its `motion`/`button` impls call
+ // straight through to the handle and never call `frame`). `frame` is
+ // what tells a client "the events since the last frame are one atomic
+ // update, process them now" - required by the protocol since
+ // `wl_pointer` version 5, and this compositor advertises v9. Without
+ // it, any client that correctly waits for `frame` before acting on
+ // motion/button state (most modern toolkits, confirmed live: neither
+ // Firefox nor wezterm registered a click or a drag-selection, in both
+ // cases with the cursor sitting squarely on the target) never actually
+ // processes what it was sent, even though every event up to this point
+ // was individually correct. This is likely the real root cause behind
+ // this whole session's "clicking/scrolling doesn't work" reports --
+ // every fix so far (subsurface routing, decoration geometry, app_id)
+ // was real and necessary, but none of them could have mattered if the
+ // client was never told to look at what it received.
+ pointer.frame(state);
+
+ update_cursor_shape(state, hit, over_layer_surface, over_content);
let mut wm = state.wm.borrow_mut();
let dragging_or_resizing = wm.is_dragging() || wm.is_resizing();
@@ -104,6 +206,65 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log
}
}
+/// Sets the pointer to a resize-direction shape while hovering (or
+/// actively dragging) one of our own decoration's resize edges, and back
+/// to the default arrow when leaving our decoration for anything else.
+///
+/// Only ever touches `cursor_status` for our own decoration - never while
+/// `layer_hit`/client content has focus, since a client surface drives its
+/// own cursor via `wl_pointer.set_cursor` once it starts receiving
+/// `pointer.motion()`/`enter` (already sent above, by the time this runs),
+/// and stomping on that here would fight the client for control of its own
+/// cursor rather than just leaving it alone.
+///
+/// Without this, `cursor_status` was only ever set by client requests --
+/// nothing on the compositor's own side ever asked for a resize cursor at
+/// all, so hovering or dragging one of our own decoration's edges never
+/// looked any different from hovering plain content, regardless of what
+/// shapes `cursor.rs` can actually render.
+///
+/// `over_content` distinguishes "over a client surface that will drive its
+/// own cursor" from "over the bare desktop, where nothing ever will" --
+/// without it, dragging off one of our decoration's resize edges straight
+/// onto empty desktop left `cursor_status` stuck on that resize icon
+/// forever: there is no client there to ever call `set_cursor` and reset
+/// it, and this function's own early-return (for the "let the client drive
+/// it" case) doesn't distinguish an *absent* client from a slow one.
+fn update_cursor_shape(state: &mut CompState, hit: Option<(WindowId, TitlebarHit)>, over_layer_surface: bool, over_content: bool) {
+ use smithay::input::pointer::{CursorIcon, CursorImageStatus};
+
+ if over_layer_surface {
+ return;
+ }
+ let edge = match hit {
+ Some((_, TitlebarHit::Resize(edge))) => Some(edge),
+ _ => state.wm.borrow().resize_edge(),
+ };
+ let icon = match edge {
+ Some(edge) => resize_cursor_icon(edge),
+ // Hovering our own decoration but not an edge (the drag area, a
+ // button) and not actively resizing: back to the plain arrow.
+ None if hit.is_some() => CursorIcon::Default,
+ // Over a client's own content: leave `cursor_status` alone, per the
+ // doc comment above - the client drives it.
+ None if over_content => return,
+ // Bare desktop: nothing else will ever reset this, so we have to.
+ None => CursorIcon::Default,
+ };
+ state.cursor_status = CursorImageStatus::Named(icon);
+}
+
+fn resize_cursor_icon(edge: srdwm_core::ResizeEdge) -> smithay::input::pointer::CursorIcon {
+ use smithay::input::pointer::CursorIcon;
+ use srdwm_core::ResizeEdge;
+ match edge {
+ ResizeEdge::Left | ResizeEdge::Right => CursorIcon::EwResize,
+ ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::NsResize,
+ ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize,
+ ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize,
+ }
+}
+
/// The underlying `wl_surface` for a mapped window, regardless of whether
/// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` --
/// `desktop::Window` exposes these as two separate accessors with no
@@ -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<f64, Logical>, button: u32, pressed: bool, time: u32) {
+ notify_idle_activity(state);
const BTN_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111;
+ const BTN_MIDDLE: u32 = 0x112;
let serial = SERIAL_COUNTER.next_serial();
// Locked: forward the click to the lock surface (it may have a button or
@@ -148,10 +333,31 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
if let Some(pointer) = state.seat.get_pointer() {
let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
+ pointer.frame(state);
}
return;
}
+ // The context menu, if open, captures every press: a click inside
+ // resolves whichever row it landed on, a click anywhere else just
+ // dismisses it. Neither case falls through to the normal handling
+ // below - opening the menu and then clicking a window underneath it
+ // should not *also* focus/raise/drag that window on the same click,
+ // the same "one click, one action" rule every native window menu
+ // follows.
+ if pressed {
+ if let Some(menu) = state.context_menu.take() {
+ if let Some(row) = menu.row_at(pos.x as i32, pos.y as i32) {
+ let (_, action) = menu.items[row];
+ state.close_context_menu();
+ state.run_context_menu_action(menu.window, action);
+ } else {
+ state.close_context_menu();
+ }
+ return;
+ }
+ }
+
// Modifier+drag: with the modifier held, dragging *anywhere* in a window
// moves it (left button) or resizes it from the nearest corner (right
// button) - the `bindm SUPER, mouse:272/273` gesture. Without this a
@@ -213,6 +419,7 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
if state.is_double_click(id, time) {
state.wm.borrow_mut().toggle_maximize(id);
state.sync_geometry(id);
+ crate::foreign_toplevel::send_state(state, id);
} else {
state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32)
}
@@ -225,8 +432,12 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
TitlebarHit::Maximize => {
state.wm.borrow_mut().toggle_maximize(id);
state.sync_geometry(id);
+ crate::foreign_toplevel::send_state(state, id);
+ }
+ TitlebarHit::Minimize => {
+ state.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(state, id);
}
- TitlebarHit::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<f64, Logic
}
}
}
+ } else if pressed && (button == BTN_RIGHT || button == BTN_MIDDLE) {
+ // Right-click a titlebar: open the window menu (minimize/maximize/
+ // pin/close) - previously nothing at all, since the only
+ // right-button behaviour anywhere was the SUPER+right-drag resize
+ // gesture above, which needs the modifier held. Middle-click:
+ // lower the window instead, the convention several X11 WMs
+ // (twm, fvwm, IceWM) have always had. Both only fire on the
+ // titlebar's plain drag area - a resize edge or one of the three
+ // buttons keeps its own single meaning regardless of which button
+ // was pressed, so a right-click on the close button, say, doesn't
+ // do something else entirely.
+ let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32);
+ if let Some((id, TitlebarHit::Drag)) = hit {
+ if button == BTN_RIGHT {
+ state.open_context_menu(id, (pos.x as i32, pos.y as i32));
+ } else {
+ state.wm.borrow_mut().lower_window(id);
+ }
+ }
} else if !pressed {
let mut wm = state.wm.borrow_mut();
- if wm.is_dragging() {
+ let was_dragging = wm.is_dragging();
+ let was_resizing = wm.is_resizing();
+ // `start_drag`/`start_resize` both focus the window they grab, and
+ // nothing else can change focus while a grab is active (the pointer
+ // is captured by the drag, not routed elsewhere) - so `focused_id`
+ // is reliably the window `end_drag`/`end_resize` are about to
+ // finish, without `WindowManager` needing to hand the id back
+ // itself.
+ let id = wm.focused_id();
+ if was_dragging {
wm.end_drag();
- } else if wm.is_resizing() {
+ } else if was_resizing {
wm.end_resize();
}
+ drop(wm);
+ // `end_drag` can snap the geometry one more time (edge/top-of-
+ // screen snapping, `SmartPlacement::snap_zone`) *after* the last
+ // `update_drag` already moved the window - without this, that
+ // final snap only ever reached `Window.geometry`. The border and
+ // titlebar redraw fresh from live geometry every frame, so they'd
+ // jump to the snapped rect immediately, while the client's actual
+ // mapped surface (driven only by `sync_geometry`'s
+ // `space.map_element`/`xdg_toplevel.configure`) stayed wherever the
+ // drag physically stopped - decoration visibly detached from its
+ // own window's content. Click routing desynced the same way:
+ // `hit_test`/`window_at` read the now-snapped `Window.geometry`
+ // while `space.element_under` still read the stale pre-snap
+ // position, so clicks in the visually-snapped zone resolved
+ // against the wrong rect. The X11 backend already gets this right
+ // (`crates/x11/src/lib.rs`'s `ButtonRelease` handler); this was the
+ // one call site in the module doc'd as "shared by both backends"
+ // that never got the same fix.
+ if was_dragging || was_resizing {
+ if let Some(id) = id {
+ state.sync_geometry(id);
+ }
+ }
}
if let Some(pointer) = state.seat.get_pointer() {
let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
+ // See the matching comment in `handle_pointer_position`: `button`
+ // alone never tells the client the event is ready to act on, only
+ // `frame` does.
+ pointer.frame(state);
}
}
@@ -260,6 +526,7 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
/// trait, so the precise-keybinding-matching logic (see the module docs)
/// only needs to exist once.
pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend, E: KeyboardKeyEvent<B>>(state: &mut CompState, event: &E) {
+ notify_idle_activity(state);
let keycode = event.key_code();
let key_state = event.state();
let time = event.time_msec();
@@ -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<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<ZwlrOutputManagerV1, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, ZwlrOutputManagerV1, _>(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<f64>, transform: Option<Transform>, mode: Option<ModeRequest> },
+}
+
+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<Mutex<HashMap<String, HeadRequest>>>;
+
+pub struct ConfigurationData {
+ serial: u32,
+ heads: ConfigMap,
+}
+
+pub struct ConfigurationHeadData {
+ head_name: String,
+ heads: ConfigMap,
+}
+
+impl GlobalDispatch<ZwlrOutputManagerV1, ()> for CompState {
+ fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ZwlrOutputManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ let manager = data_init.init(manager, ());
+ let outputs: Vec<Output> = state.outputs().cloned().collect();
+ for output in &outputs {
+ announce_head(state, &manager, output, client, dh);
+ }
+ manager.done(state.output_serial);
+ state.output_managers.push(manager);
+ }
+}
+
+impl Dispatch<ZwlrOutputManagerV1, ()> for CompState {
+ fn request(
+ _state: &mut Self,
+ _client: &Client,
+ manager: &ZwlrOutputManagerV1,
+ request: zwlr_output_manager_v1::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ match request {
+ zwlr_output_manager_v1::Request::CreateConfiguration { id, serial } => {
+ data_init.init(id, ConfigurationData { serial, heads: Arc::new(Mutex::new(HashMap::new())) });
+ }
+ zwlr_output_manager_v1::Request::Stop => {
+ manager.finished();
+ }
+ _ => {}
+ }
+ }
+
+ fn destroyed(state: &mut Self, _client: ClientId, manager: &ZwlrOutputManagerV1, _data: &()) {
+ state.output_managers.retain(|m| m != manager);
+ }
+}
+
+impl Dispatch<ZwlrOutputHeadV1, HeadData> for CompState {
+ fn request(_state: &mut Self, _client: &Client, _head: &ZwlrOutputHeadV1, _request: zwlr_output_head_v1::Request, _data: &HeadData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) {
+ // Only request is `release` (a destructor); `destroyed` below
+ // handles the cleanup for both that and a client disconnecting
+ // outright.
+ }
+
+ fn destroyed(state: &mut Self, _client: ClientId, head: &ZwlrOutputHeadV1, data: &HeadData) {
+ if let Some(handles) = state.output_heads.get_mut(&data.name) {
+ handles.retain(|h| h != head);
+ }
+ }
+}
+
+impl Dispatch<ZwlrOutputModeV1, ModeData> for CompState {
+ fn request(_state: &mut Self, _client: &Client, _mode: &ZwlrOutputModeV1, _request: zwlr_output_mode_v1::Request, _data: &ModeData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) {}
+
+ fn destroyed(state: &mut Self, _client: ClientId, mode: &ZwlrOutputModeV1, _data: &ModeData) {
+ for handles in state.output_modes.values_mut() {
+ handles.retain(|m| m != mode);
+ }
+ }
+}
+
+impl Dispatch<ZwlrOutputConfigurationV1, ConfigurationData> for CompState {
+ fn request(
+ state: &mut Self,
+ client: &Client,
+ config: &ZwlrOutputConfigurationV1,
+ request: zwlr_output_configuration_v1::Request,
+ data: &ConfigurationData,
+ dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ match request {
+ zwlr_output_configuration_v1::Request::EnableHead { id, head } => {
+ let Some(name) = head.data::<HeadData>().map(|d| d.name.clone()) else { return };
+ data.heads.lock().unwrap().insert(name.clone(), HeadRequest::Enabled { position: None, scale: None, transform: None, mode: None });
+ data_init.init(id, ConfigurationHeadData { head_name: name, heads: data.heads.clone() });
+ }
+ zwlr_output_configuration_v1::Request::DisableHead { head } => {
+ let Some(name) = head.data::<HeadData>().map(|d| d.name.clone()) else { return };
+ data.heads.lock().unwrap().insert(name, HeadRequest::Disabled);
+ }
+ zwlr_output_configuration_v1::Request::Apply => apply_or_test(state, config, data, client, dh, true),
+ zwlr_output_configuration_v1::Request::Test => apply_or_test(state, config, data, client, dh, false),
+ zwlr_output_configuration_v1::Request::Destroy => {}
+ _ => {}
+ }
+ }
+
+ fn destroyed(_state: &mut Self, _client: ClientId, _config: &ZwlrOutputConfigurationV1, _data: &ConfigurationData) {}
+}
+
+impl Dispatch<ZwlrOutputConfigurationHeadV1, ConfigurationHeadData> for CompState {
+ fn request(
+ _state: &mut Self,
+ _client: &Client,
+ _config_head: &ZwlrOutputConfigurationHeadV1,
+ request: zwlr_output_configuration_head_v1::Request,
+ data: &ConfigurationHeadData,
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, Self>,
+ ) {
+ let mut heads = data.heads.lock().unwrap();
+ let Some(HeadRequest::Enabled { position, scale, transform, mode }) = heads.get_mut(&data.head_name) else { return };
+ match request {
+ zwlr_output_configuration_head_v1::Request::SetPosition { x, y } => *position = Some((x, y)),
+ zwlr_output_configuration_head_v1::Request::SetScale { scale: s } => *scale = Some(s),
+ zwlr_output_configuration_head_v1::Request::SetTransform { transform: t } => {
+ if let WEnum::Value(t) = t {
+ *transform = Some(wl_transform_to_smithay(t));
+ }
+ }
+ zwlr_output_configuration_head_v1::Request::SetMode { mode: mode_obj } => {
+ if let Some(m) = mode_obj.data::<ModeData>() {
+ *mode = Some(ModeRequest::Existing(m.mode));
+ }
+ }
+ zwlr_output_configuration_head_v1::Request::SetCustomMode { width, height, refresh } => {
+ *mode = Some(ModeRequest::Custom { width, height, refresh });
+ }
+ _ => {}
+ }
+ }
+
+ fn destroyed(_state: &mut Self, _client: ClientId, _config_head: &ZwlrOutputConfigurationHeadV1, _data: &ConfigurationHeadData) {}
+}
+
+/// `WEnum<wl_output::Transform>` has no built-in conversion to smithay's
+/// own `Transform` (the reverse direction, smithay `Transform` -> wire, is
+/// provided by smithay itself and already used elsewhere in this crate --
+/// this is the one direction it doesn't cover, needed here because this is
+/// the first place in this codebase that has to *decode* a client-sent
+/// transform rather than only ever sending one).
+fn wl_transform_to_smithay(t: smithay::reexports::wayland_server::protocol::wl_output::Transform) -> Transform {
+ use smithay::reexports::wayland_server::protocol::wl_output::Transform as Wl;
+ match t {
+ Wl::Normal => Transform::Normal,
+ Wl::_90 => Transform::_90,
+ Wl::_180 => Transform::_180,
+ Wl::_270 => Transform::_270,
+ Wl::Flipped => Transform::Flipped,
+ Wl::Flipped90 => Transform::Flipped90,
+ Wl::Flipped180 => Transform::Flipped180,
+ Wl::Flipped270 => Transform::Flipped270,
+ _ => Transform::Normal,
+ }
+}
+
+/// Whether a client's requested mode is a no-op relative to what's already
+/// active - the one `set_mode`/`set_custom_mode` case this module accepts
+/// (see its own doc comment on why a genuinely *different* mode doesn't).
+/// A custom mode's `refresh` of `0` means "unspecified", so it matches any
+/// refresh rate the current mode happens to have.
+fn mode_request_matches_current(req: &ModeRequest, current: Option<OutputMode>) -> bool {
+ match req {
+ ModeRequest::Existing(m) => current == Some(*m),
+ ModeRequest::Custom { width, height, refresh } => current.is_some_and(|m| m.size.w == *width && m.size.h == *height && (*refresh == 0 || m.refresh == *refresh)),
+ }
+}
+
+/// Validates every head in `data.heads` against live state, then - only
+/// for `apply`, never `test` - actually changes it. All-or-nothing: if
+/// any head's request can't be honoured, nothing is applied and `failed`
+/// is sent, matching the protocol's own framing of a configuration as one
+/// atomic unit. See the module doc comment for exactly what "can't be
+/// honoured" covers.
+fn apply_or_test(state: &mut CompState, config: &ZwlrOutputConfigurationV1, data: &ConfigurationData, _client: &Client, _dh: &DisplayHandle, apply: bool) {
+ if data.serial != state.output_serial {
+ config.cancelled();
+ return;
+ }
+ let heads = data.heads.lock().unwrap();
+ for (name, req) in heads.iter() {
+ let Some(output) = state.outputs().find(|o| &o.name() == name).cloned() else {
+ config.failed();
+ return;
+ };
+ match req {
+ HeadRequest::Disabled => {
+ config.failed();
+ return;
+ }
+ HeadRequest::Enabled { mode, .. } => {
+ let Some(mode_req) = mode else { continue };
+ if !mode_request_matches_current(mode_req, output.current_mode()) {
+ config.failed();
+ return;
+ }
+ }
+ }
+ }
+
+ if apply {
+ for (name, req) in heads.iter() {
+ let HeadRequest::Enabled { position, scale, transform, .. } = req else { continue };
+ let Some(output) = state.outputs().find(|o| &o.name() == name).cloned() else { continue };
+ if let Some(t) = transform {
+ output.change_current_state(None, Some(*t), None, None);
+ }
+ if let Some(s) = scale {
+ output.change_current_state(None, None, Some(Scale::Fractional(*s)), None);
+ }
+ if let Some((x, y)) = position {
+ apply_output_position(state, &output, Point::from((*x, *y)));
+ }
+ }
+ }
+ drop(heads);
+
+ config.succeeded();
+ if apply {
+ broadcast_dirty_outputs(state);
+ }
+}
+
+/// Moves an output and keeps every place its position is separately
+/// cached in step - `Output` itself, `CompState::outputs` (used for hit-
+/// testing/`output_at`) and, on the udev backend, `UdevHead::location`
+/// (used to translate render geometry into head-local space) each keep
+/// their own copy for reasons documented on their own fields, and would
+/// otherwise silently drift from what `Output` now reports.
+fn apply_output_position(state: &mut CompState, output: &Output, new_location: Point<i32, smithay::utils::Logical>) {
+ output.change_current_state(None, None, None, Some(new_location));
+ if let Some(entry) = state.outputs.iter_mut().find(|e| &e.output == output) {
+ entry.location = new_location;
+ }
+ if let Some(udev) = state.udev.as_mut() {
+ if let Some(head) = udev.heads.iter_mut().find(|h| &h.output == output) {
+ head.location = new_location;
+ }
+ }
+}
+
+fn announce_head(state: &mut CompState, manager: &ZwlrOutputManagerV1, output: &Output, client: &Client, dh: &DisplayHandle) {
+ let Ok(head) = client.create_resource::<ZwlrOutputHeadV1, HeadData, CompState>(dh, manager.version(), HeadData { name: output.name() }) else {
+ return;
+ };
+ manager.head(&head);
+
+ head.name(output.name());
+ head.description(output.description());
+ let phys = output.physical_properties();
+ if phys.size.w > 0 && phys.size.h > 0 {
+ head.physical_size(phys.size.w, phys.size.h);
+ }
+ if !phys.make.is_empty() {
+ head.make(phys.make.clone());
+ }
+ if !phys.model.is_empty() {
+ head.model(phys.model.clone());
+ }
+
+ let modes = output.modes();
+ let preferred = output.preferred_mode();
+ let current = output.current_mode();
+ let mut current_mode_handle = None;
+ for m in &modes {
+ let Ok(mode_handle) = client.create_resource::<ZwlrOutputModeV1, ModeData, CompState>(dh, manager.version(), ModeData { mode: *m }) else {
+ continue;
+ };
+ head.mode(&mode_handle);
+ mode_handle.size(m.size.w, m.size.h);
+ if m.refresh > 0 {
+ mode_handle.refresh(m.refresh);
+ }
+ if Some(*m) == preferred {
+ mode_handle.preferred();
+ }
+ if Some(*m) == current {
+ current_mode_handle = Some(mode_handle.clone());
+ }
+ state.output_modes.entry(output.name()).or_default().push(mode_handle);
+ }
+
+ // srdwm has no disabled-output concept - see the module doc comment --
+ // so every head it ever advertises is always enabled.
+ head.enabled(1);
+ if let Some(mode_handle) = &current_mode_handle {
+ head.current_mode(mode_handle);
+ }
+ let loc = output.current_location();
+ head.position(loc.x, loc.y);
+ head.transform(output.current_transform().into());
+ head.scale(output.current_scale().fractional_scale());
+
+ state.output_heads.entry(output.name()).or_default().push(head);
+}
+
+/// One output's state as far as this protocol cares, for the cheap
+/// per-frame equality check `broadcast_dirty_outputs` gates on - see
+/// `foreign_toplevel::broadcast_dirty_state`/`workspace::
+/// broadcast_dirty_active`'s doc comments for the same "diff once a tick,
+/// only do real work on an actual change" shape used throughout this
+/// crate's dock/panel-facing protocols. Scale is compared as milli-units
+/// (`* 1000.0` rounded) rather than the raw `f64`, so this can derive a
+/// plain `PartialEq` instead of needing a fuzzy float comparison.
+#[derive(Clone, PartialEq)]
+pub(crate) struct OutputSnapshot {
+ name: String,
+ x: i32,
+ y: i32,
+ scale_millis: i64,
+ transform: Transform,
+ mode: Option<(i32, i32, i32)>,
+}
+
+fn snapshot(output: &Output) -> OutputSnapshot {
+ let loc = output.current_location();
+ let mode = output.current_mode().map(|m| (m.size.w, m.size.h, m.refresh));
+ OutputSnapshot {
+ name: output.name(),
+ x: loc.x,
+ y: loc.y,
+ scale_millis: (output.current_scale().fractional_scale() * 1000.0).round() as i64,
+ transform: output.current_transform(),
+ mode,
+ }
+}
+
+/// Called once a frame from `CompState::tick_dirty_broadcasts`. Diffs the
+/// live output set/state against what was last broadcast and, only on a
+/// real change, creates/destroys head objects for any output that
+/// appeared/disappeared and re-sends current state (enabled/current_mode/
+/// position/transform/scale) on every other - covers both real hotplug
+/// and an `apply()` that just went through, without either needing its own
+/// separate notification path.
+pub(crate) fn broadcast_dirty_outputs(state: &mut CompState) {
+ let current: Vec<OutputSnapshot> = state.outputs().map(snapshot).collect();
+ if current == state.last_broadcast_outputs {
+ return;
+ }
+ let current_names: HashSet<String> = current.iter().map(|s| s.name.clone()).collect();
+ let previous_names: HashSet<String> = state.last_broadcast_outputs.iter().map(|s| s.name.clone()).collect();
+ state.last_broadcast_outputs = current;
+
+ for name in previous_names.difference(&current_names) {
+ if let Some(handles) = state.output_heads.remove(name) {
+ for h in handles {
+ h.finished();
+ }
+ }
+ state.output_modes.remove(name);
+ }
+
+ let added: Vec<String> = current_names.difference(&previous_names).cloned().collect();
+ if !added.is_empty() {
+ let managers = state.output_managers.clone();
+ for manager in &managers {
+ let Some(client) = manager.client() else { continue };
+ let dh = state.dh.clone();
+ for name in &added {
+ let found: Option<Output> = state.outputs().find(|o| &o.name() == name).cloned();
+ if let Some(output) = found {
+ announce_head(state, manager, &output, &client, &dh);
+ }
+ }
+ }
+ }
+
+ let outputs: Vec<Output> = state.outputs().cloned().collect();
+ for output in &outputs {
+ let Some(handles) = state.output_heads.get(&output.name()).cloned() else { continue };
+ let mode_handles = state.output_modes.get(&output.name()).cloned().unwrap_or_default();
+ let current_mode = output.current_mode();
+ let matching_mode_handle = current_mode.and_then(|m| mode_handles.iter().find(|mh| mh.data::<ModeData>().is_some_and(|d| d.mode == m)).cloned());
+ let loc = output.current_location();
+ for head in &handles {
+ head.enabled(1);
+ if let Some(mh) = &matching_mode_handle {
+ head.current_mode(mh);
+ }
+ head.position(loc.x, loc.y);
+ head.transform(output.current_transform().into());
+ head.scale(output.current_scale().fractional_scale());
+ }
+ }
+
+ state.output_serial += 1;
+ let serial = state.output_serial;
+ for manager in state.output_managers.clone() {
+ manager.done(serial);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn mode(w: i32, h: i32, refresh: i32) -> OutputMode {
+ OutputMode { size: (w, h).into(), refresh }
+ }
+
+ #[test]
+ fn existing_mode_request_matches_only_the_identical_mode() {
+ let m = mode(1920, 1080, 60000);
+ assert!(mode_request_matches_current(&ModeRequest::Existing(m), Some(m)));
+ assert!(!mode_request_matches_current(&ModeRequest::Existing(m), Some(mode(1280, 720, 60000))));
+ assert!(!mode_request_matches_current(&ModeRequest::Existing(m), None));
+ }
+
+ #[test]
+ fn custom_mode_request_matches_same_size_and_refresh() {
+ let req = ModeRequest::Custom { width: 1920, height: 1080, refresh: 60000 };
+ assert!(mode_request_matches_current(&req, Some(mode(1920, 1080, 60000))));
+ assert!(!mode_request_matches_current(&req, Some(mode(1920, 1080, 59940))));
+ assert!(!mode_request_matches_current(&req, Some(mode(1280, 720, 60000))));
+ }
+
+ #[test]
+ fn custom_mode_request_with_zero_refresh_matches_any_refresh_at_that_size() {
+ let req = ModeRequest::Custom { width: 1920, height: 1080, refresh: 0 };
+ assert!(mode_request_matches_current(&req, Some(mode(1920, 1080, 60000))));
+ assert!(mode_request_matches_current(&req, Some(mode(1920, 1080, 59940))));
+ assert!(!mode_request_matches_current(&req, Some(mode(1280, 720, 60000))));
+ }
+
+ #[test]
+ fn snapshot_equality_ignores_float_noise_below_a_thousandth() {
+ let a = OutputSnapshot { name: "HDMI-A-1".into(), x: 0, y: 0, scale_millis: 1500, transform: Transform::Normal, mode: Some((1920, 1080, 60000)) };
+ let b = OutputSnapshot { name: "HDMI-A-1".into(), x: 0, y: 0, scale_millis: 1500, transform: Transform::Normal, mode: Some((1920, 1080, 60000)) };
+ assert!(a == b);
+ }
+
+ #[test]
+ fn snapshot_inequality_catches_a_position_change() {
+ let a = OutputSnapshot { name: "HDMI-A-1".into(), x: 0, y: 0, scale_millis: 1000, transform: Transform::Normal, mode: None };
+ let b = OutputSnapshot { name: "HDMI-A-1".into(), x: 1920, y: 0, scale_millis: 1000, transform: Transform::Normal, mode: None };
+ assert!(a != b);
+ }
+
+ #[test]
+ fn wl_transform_round_trips_through_smithay_and_back() {
+ use smithay::reexports::wayland_server::protocol::wl_output::Transform as Wl;
+ for (wl, expected) in [
+ (Wl::Normal, Transform::Normal),
+ (Wl::_90, Transform::_90),
+ (Wl::_180, Transform::_180),
+ (Wl::_270, Transform::_270),
+ (Wl::Flipped, Transform::Flipped),
+ (Wl::Flipped90, Transform::Flipped90),
+ (Wl::Flipped180, Transform::Flipped180),
+ (Wl::Flipped270, Transform::Flipped270),
+ ] {
+ assert_eq!(wl_transform_to_smithay(wl), expected);
+ let back: Wl = expected.into();
+ assert_eq!(back, wl);
+ }
+ }
+}
diff --git a/crates/wayland/src/output_power.rs b/crates/wayland/src/output_power.rs
new file mode 100644
index 0000000..e0734f8
--- /dev/null
+++ b/crates/wayland/src/output_power.rs
@@ -0,0 +1,99 @@
+//! `zwlr_output_power_management_v1`: lets a client (a settings panel, an
+//! idle daemon) put a specific output into DPMS off/on.
+//!
+//! Deliberately separate from `ext_idle_notify_v1`/`zwp_idle_inhibit_manager_v1`
+//! (`protocols.rs`): that protocol only *tells* a client the seat has gone
+//! idle, it has no way to blank a screen itself. A real "turn the display
+//! off after N minutes idle" feature needs both - an idle daemon watches
+//! `ext_idle_notify_v1` and calls this protocol's `set_mode` in response.
+//! Neither protocol implies the other.
+//!
+//! DRM/udev backend only: there is no real display to power down when
+//! nested under a host compositor (`winit.rs`) - the host owns the actual
+//! screen, and turning off the *nested window* makes no sense. The global
+//! is simply never created there (`CompState::_output_power_state` is
+//! `None`), so a client sees the protocol as genuinely unsupported rather
+//! than advertised-but-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`/`foreign_toplevel.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::output_power_management::v1::server::zwlr_output_power_manager_v1::{self, ZwlrOutputPowerManagerV1};
+use wayland_protocols_wlr::output_power_management::v1::server::zwlr_output_power_v1::{self, Mode, ZwlrOutputPowerV1};
+
+use crate::state::CompState;
+
+/// The manager global. Held by `CompState` purely to keep the global alive
+/// for the compositor's lifetime.
+pub struct OutputPowerManagerState {
+ _global: GlobalId,
+}
+
+impl OutputPowerManagerState {
+ pub fn new<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<ZwlrOutputPowerManagerV1, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, ZwlrOutputPowerManagerV1, _>(1, ()) }
+ }
+}
+
+/// Which `wl_output` a `zwlr_output_power_v1` object controls, resolved
+/// once at creation. `set_mode` re-resolves it against the live output list
+/// rather than trusting this stays valid - a client is free to hold the
+/// object across a monitor unplug.
+pub struct OutputPowerData {
+ output: WlOutput,
+}
+
+impl GlobalDispatch<ZwlrOutputPowerManagerV1, ()> for CompState {
+ fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New<ZwlrOutputPowerManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ data_init.init(manager, ());
+ }
+}
+
+impl Dispatch<ZwlrOutputPowerManagerV1, ()> for CompState {
+ fn request(
+ _state: &mut Self,
+ _client: &Client,
+ _manager: &ZwlrOutputPowerManagerV1,
+ request: zwlr_output_power_manager_v1::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ use zwlr_output_power_manager_v1::Request;
+ if let Request::GetOutputPower { id, output } = request {
+ let power = data_init.init(id, OutputPowerData { output });
+ // Per protocol: "sent immediately when the object is created
+ // so the client is informed about the current power management
+ // mode" - this compositor has no notion of a monitor starting
+ // powered off, so always On at creation.
+ power.mode(Mode::On);
+ }
+ }
+}
+
+impl Dispatch<ZwlrOutputPowerV1, OutputPowerData> for CompState {
+ fn request(
+ state: &mut Self,
+ _client: &Client,
+ resource: &ZwlrOutputPowerV1,
+ request: zwlr_output_power_v1::Request,
+ data: &OutputPowerData,
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, Self>,
+ ) {
+ let zwlr_output_power_v1::Request::SetMode { mode } = request else { return };
+ let Ok(mode) = mode.into_result() else { return };
+ match state.set_output_power(&data.output, mode == Mode::On) {
+ Some(()) => resource.mode(mode),
+ None => resource.failed(),
+ }
+ }
+}
diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs
index f51b35d..a0aed3f 100644
--- a/crates/wayland/src/protocols.rs
+++ b/crates/wayland/src/protocols.rs
@@ -7,34 +7,44 @@
//! session-lock handler is the one exception, living in [`crate::lock`]
//! alongside the rest of that feature.
-use smithay::desktop::{layer_map_for_output, LayerSurface as DesktopLayerSurface};
-use smithay::input::pointer::CursorImageStatus;
+use smithay::desktop::{find_popup_root_surface, layer_map_for_output, LayerSurface as DesktopLayerSurface, PopupKeyboardGrab, PopupKind, PopupPointerGrab};
+use smithay::input::pointer::{CursorImageStatus, Focus};
use smithay::input::{Seat, SeatHandler, SeatState};
use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode;
+use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
use smithay::reexports::wayland_server::protocol::wl_seat;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::Client;
+use smithay::reexports::wayland_server::Resource;
+use smithay::backend::allocator::dmabuf::Dmabuf;
+use smithay::backend::renderer::ImportDma;
use smithay::utils::Serial;
use smithay::wayland::buffer::BufferHandler;
use smithay::wayland::compositor::{CompositorClientState, CompositorHandler, CompositorState};
+use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
+use smithay::wayland::xdg_activation::{XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData};
+use smithay::wayland::input_method::PopupSurface as ImePopupSurface;
use smithay::wayland::selection::data_device::{
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler,
};
use smithay::wayland::selection::primary_selection::{PrimarySelectionHandler, PrimarySelectionState};
use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState};
+use smithay::wayland::compositor::{add_pre_commit_hook, with_states};
use smithay::wayland::selection::SelectionHandler;
use smithay::wayland::shell::wlr_layer::{
- Layer, LayerSurface as WlrLayerSurface, WlrLayerShellHandler, WlrLayerShellState,
+ Layer, LayerSurface as WlrLayerSurface, LayerSurfaceCachedState, WlrLayerShellHandler, WlrLayerShellState,
};
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
use smithay::wayland::shell::xdg::{PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState};
use smithay::wayland::shm::{ShmHandler, ShmState};
+use smithay::wayland::tablet_manager::TabletSeatHandler;
use smithay::{
- delegate_compositor, delegate_data_control, delegate_data_device, delegate_layer_shell, delegate_output,
- delegate_primary_selection, delegate_seat, delegate_session_lock, delegate_shm, delegate_xdg_decoration,
- delegate_xdg_shell,
+ delegate_compositor, delegate_cursor_shape, delegate_data_control, delegate_data_device, delegate_dmabuf,
+ delegate_layer_shell, delegate_output, delegate_primary_selection, delegate_seat, delegate_session_lock,
+ delegate_shm, delegate_xdg_activation, delegate_xdg_decoration, delegate_xdg_shell,
+ delegate_input_method_manager, delegate_text_input_manager,
};
use crate::state::{ClientState, CompState};
@@ -55,6 +65,52 @@ impl CompositorHandler for CompState {
&client.get_data::<smithay::xwayland::XWaylandClientData>().expect("client is neither ours nor XWayland's").compositor_state
}
+ /// Workaround for a real smithay bug (see docs/PANEL_SUPPORT_TODO.md and
+ /// `layer_destroyed` below): destroying a `zwlr_layer_surface_v1` role
+ /// resets the surface's `LayerSurfaceCachedState` to
+ /// `Default::default()` (size 0x0, no anchor) rather than removing it,
+ /// but the pre-commit hook smithay itself registers at
+ /// `get_layer_surface` time keeps validating that state against every
+ /// future commit regardless of whether the role still exists --
+ /// tripping its own `width/height 0 requested without ... anchors`
+ /// check and posting `invalid_size`, which kills the client's whole
+ /// connection over what is protocol-legal (committing a now-roleless
+ /// surface).
+ ///
+ /// Fixed by registering our own pre-commit hook here, in `new_surface`
+ /// - called at `wl_compositor.create_surface`, strictly before any
+ /// later `get_layer_surface` on the same surface could register
+ /// smithay's own hook. Hooks run in registration order (`tree.rs`:
+ /// `pre_commit_hooks` is a plain `Vec`, pushed and iterated in order),
+ /// so ours always runs first and can neutralize the stale reset state
+ /// before smithay's hook ever inspects it. This depends on that
+ /// ordering guarantee holding in future smithay versions - it isn't
+ /// documented as an API contract, just an implementation detail
+ /// confirmed against 0.7.0's source - so re-check this file against
+ /// whatever smithay version replaces it.
+ ///
+ /// Cost: one closure registered per `wl_surface` (not just layer
+ /// surfaces, since we don't know in advance which ones will become
+ /// one), each a no-op unless that exact surface is in
+ /// `dead_layer_surfaces`.
+ fn new_surface(&mut self, surface: &WlSurface) {
+ add_pre_commit_hook::<CompState, _>(surface, |state, _dh, surface| {
+ if !state.dead_layer_surfaces.contains(surface) {
+ return;
+ }
+ with_states(surface, |states| {
+ let mut cached = states.cached_state.get::<LayerSurfaceCachedState>();
+ let pending = cached.pending();
+ if pending.size.w == 0 && !pending.anchor.anchored_horizontally() {
+ pending.size.w = 1;
+ }
+ if pending.size.h == 0 && !pending.anchor.anchored_vertically() {
+ pending.size.h = 1;
+ }
+ });
+ });
+ }
+
fn commit(&mut self, surface: &WlSurface) {
smithay::backend::renderer::utils::on_commit_buffer_handler::<CompState>(surface);
// XWayland's association of an X11 window with this wl_surface can
@@ -67,8 +123,16 @@ impl CompositorHandler for CompState {
if let Some(w) = self.id_to_window.get(&id) {
w.on_commit();
}
+ crate::state::sync_toplevel_metadata(self, id, surface);
}
self.ensure_layer_initial_configure(surface);
+ // Advances a just-created popup from unmapped to mapped (needed for
+ // `PopupManager::popups_for_surface`, which `popup_render_elements`
+ // reads at render time) and prunes dead ones. Cheap and only does
+ // real work on a popup-role surface, so doing it on every commit
+ // rather than throttling is not worth the extra bookkeeping.
+ self.popups.commit(surface);
+ self.popups.cleanup();
}
}
@@ -81,11 +145,204 @@ impl XdgShellHandler for CompState {
self.new_managed_window(surface);
}
- fn new_popup(&mut self, _surface: PopupSurface, _positioner: PositionerState) {}
+ /// `move_request`/`resize_request` were also still smithay's default
+ /// no-op implementations - a much larger gap than the five below:
+ /// this is *how a client-side-decorated window gets dragged or resized
+ /// by its own titlebar/edges at all*. A window we draw our own
+ /// decoration for never needed this (`TitlebarHit::Drag`/`Resize` in
+ /// `input.rs` detect the click directly, since we own those pixels),
+ /// but a window that negotiated client-side decoration and draws its
+ /// own titlebar - Firefox, and most GTK4 apps by default - handles
+ /// the click itself and then asks the compositor to actually perform
+ /// the move/resize via exactly these two requests. Left unimplemented,
+ /// dragging or resizing any such window by its own chrome did
+ /// nothing at all - the only way to reposition it was the
+ /// modifier+drag-anywhere gesture (`bindm`), which most users have no
+ /// reason to know exists and doesn't work for resize-from-a-specific-
+ /// edge at all. Reuses the exact same `WindowManager::start_drag`/
+ /// `start_resize` the pointer-driven titlebar handlers call --
+ /// `handle_pointer_position`/`handle_pointer_button` already drive any
+ /// in-progress drag/resize to completion on subsequent motion/release
+ /// regardless of what started it, so no smithay pointer grab is
+ /// needed here at all, just the same start call from a different
+ /// trigger.
+ fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ let pos = crate::input::last_pointer_pos(self);
+ self.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32);
+ }
+ }
+
+ fn resize_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial, edges: xdg_toplevel::ResizeEdge) {
+ let Some(edge) = (match edges {
+ xdg_toplevel::ResizeEdge::Top => Some(srdwm_core::ResizeEdge::Top),
+ xdg_toplevel::ResizeEdge::Bottom => Some(srdwm_core::ResizeEdge::Bottom),
+ xdg_toplevel::ResizeEdge::Left => Some(srdwm_core::ResizeEdge::Left),
+ xdg_toplevel::ResizeEdge::Right => Some(srdwm_core::ResizeEdge::Right),
+ xdg_toplevel::ResizeEdge::TopLeft => Some(srdwm_core::ResizeEdge::TopLeft),
+ xdg_toplevel::ResizeEdge::TopRight => Some(srdwm_core::ResizeEdge::TopRight),
+ xdg_toplevel::ResizeEdge::BottomLeft => Some(srdwm_core::ResizeEdge::BottomLeft),
+ xdg_toplevel::ResizeEdge::BottomRight => Some(srdwm_core::ResizeEdge::BottomRight),
+ // `None` is a valid protocol value (the client leaves the edge
+ // unspecified) but `WindowManager::start_resize` needs one --
+ // there's nothing sensible to default it to that wouldn't be a
+ // guess, so this is a no-op rather than picking one.
+ _ => None,
+ }) else {
+ return;
+ };
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ let pos = crate::input::last_pointer_pos(self);
+ self.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32);
+ }
+ }
+
+ /// `maximize_request`/`unmaximize_request`/`fullscreen_request`/
+ /// `unfullscreen_request`/`minimize_request` were all still smithay's
+ /// default no-op (or configure-only) implementations - found
+ /// investigating the `toggle_fullscreen` decoration bug above, by
+ /// checking what else routes through the same `WindowManager` calls
+ /// the titlebar-button click handlers in `input.rs` already use.
+ /// These five are the *client-initiated* equivalent of those clicks: a
+ /// client's own window-menu "Maximize", pressing F11, an HTML5 video
+ /// going fullscreen, or (for a client that negotiated client-side
+ /// decoration and draws its own titlebar, like Firefox) that titlebar's
+ /// own maximize button - all ask the compositor to actually perform
+ /// the state change via these requests rather than the compositor
+ /// noticing on its own. Left unimplemented, every one of them was a
+ /// silent no-op: the client's button did nothing, with no error and
+ /// nothing to suggest why, from any app that relies on this instead of
+ /// (or in addition to) a compositor-side keybinding.
+ fn maximize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ fn unmaximize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ /// `_output` (the client's requested target output) is ignored --
+ /// single-seat, and every other fullscreen entry point (the titlebar
+ /// button, `srd.window.fullscreen()`) already fullscreens on whatever
+ /// monitor the window is already on, so this matches that instead of
+ /// introducing an output-aware fullscreen path only this one request
+ /// would use.
+ fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<WlOutput>) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if !self.wm.borrow().is_fullscreen(id) {
+ // `redraw_decoration_buffer` first, same reason
+ // `set_decorated_from_mode` calls it before `sync_geometry`:
+ // fullscreen also flips `Window.decorated`, and dropping
+ // the decoration needs the buffer actually removed, not
+ // just left stale for `sync_geometry`'s own resize-only
+ // redraw check to skip.
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if self.wm.borrow().is_fullscreen(id) {
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
- fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) {}
+ /// No `send_configure` here, matching the pointer-driven
+ /// `TitlebarHit::Minimize` handler in `input.rs`: minimizing doesn't
+ /// change the window's own size, only whether it's currently shown, so
+ /// there's nothing new to tell the client about its own geometry.
+ fn minimize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ self.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
- fn reposition_request(&mut self, _surface: PopupSurface, _positioner: PositionerState, _token: u32) {}
+ /// Was a bare no-op - no `send_configure` at all. Per xdg-shell,
+ /// `xdg_surface.configure` is required before a popup's first commit;
+ /// real toolkits (confirmed live: GTK4's Wayland backend) block that
+ /// commit in a synchronous roundtrip waiting for it, so every popup
+ /// hung its client forever. GTK4 implements tooltips *and*
+ /// `Gtk.Popover` as `xdg_popup`, so this fired on hovering almost any
+ /// widget with a tooltip - confirmed by a peer session's gdb backtrace
+ /// (blocked in `wl_display_dispatch_queue` under `gtk_widget_show`)
+ /// after AGS wedged.
+ ///
+ /// Geometry is `positioner.get_geometry()` un-constrained - no
+ /// on-screen clamping yet (`PositionerState::get_unconstrained_geometry`
+ /// needs a target rect in the parent's surface-local space, which is a
+ /// real follow-up, not this fix); an occasional popup placed near a
+ /// screen edge may render partly off it, which is cosmetic, not a hang.
+ fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) {
+ surface.with_pending_state(|state| {
+ state.geometry = positioner.get_geometry();
+ state.positioner = positioner;
+ });
+ if surface.send_configure().is_err() {
+ return;
+ }
+ let _ = self.popups.track_popup(smithay::desktop::PopupKind::Xdg(surface));
+ }
+
+ /// Implicit grab + dismiss-on-outside-click. Previously believed
+ /// blocked on `CompState`'s `SeatHandler` associated types not
+ /// satisfying `PopupManager::grab_popup`'s `WaylandFocus +
+ /// From<PopupKind>` bound - rechecked while implementing
+ /// `move_request`/`resize_request` (same trait, adjacent methods) and
+ /// it turns out they already do: `KeyboardFocus`/`PointerFocus` are
+ /// both plain `WlSurface`, smithay provides `impl From<PopupKind> for
+ /// WlSurface` itself, and `WlSurface: From<WlSurface>` trivially. No
+ /// blocker ever existed by the time of this pass; the bound just
+ /// hadn't been rechecked since being noted as unmet.
+ ///
+ /// `self.seat.clone()` rather than resolving `_seat` (the client's
+ /// `wl_seat` resource) via `Seat::from_resource` - this compositor
+ /// only ever has the one seat, matching how `move_request`/
+ /// `resize_request` already ignore the same parameter.
+ fn grab(&mut self, surface: PopupSurface, _seat: wl_seat::WlSeat, serial: Serial) {
+ let popup = PopupKind::Xdg(surface);
+ let Ok(root) = find_popup_root_surface(&popup) else { return };
+ let seat = self.seat.clone();
+ let Ok(grab) = self.popups.grab_popup(root, popup, &seat, serial) else { return };
+ if let Some(keyboard) = seat.get_keyboard() {
+ keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial);
+ }
+ if let Some(pointer) = seat.get_pointer() {
+ pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep);
+ }
+ }
+
+ fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) {
+ surface.with_pending_state(|state| {
+ state.geometry = positioner.get_geometry();
+ state.positioner = positioner;
+ });
+ surface.send_repositioned(token);
+ }
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
self.remove_window(surface.wl_surface());
@@ -99,14 +356,42 @@ impl XdgDecorationHandler for CompState {
});
}
- fn request_mode(&mut self, toplevel: ToplevelSurface, _mode: DecorationMode) {
+ /// Honors whichever mode the client actually asked for, rather than
+ /// always forcing server-side - and mirrors the result into our own
+ /// `Window.decorated`, so a client drawing its own titlebar doesn't
+ /// *also* get one drawn on top of it by us.
+ ///
+ /// Always forcing `ServerSide` (what this used to do) is why some
+ /// clients ended up with two sets of window buttons: Firefox requests
+ /// client-side decoration when its own "use system titlebar" setting
+ /// is off, and draws its own close/minimize/maximize row regardless of
+ /// what the compositor grants - so forcing server-side just added
+ /// srdwm's row on top of the one Firefox was drawing anyway, instead
+ /// of preventing it. Respecting the request means srdwm steps out of
+ /// the way for exactly those clients, while everything that accepts
+ /// (or has no preference and gets offered) server-side still gets our
+ /// titlebar as before.
+ fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) {
toplevel.with_pending_state(|state| {
- state.decoration_mode = Some(DecorationMode::ServerSide);
+ state.decoration_mode = Some(mode);
});
toplevel.send_configure();
+ self.set_decorated_from_mode(toplevel.wl_surface(), mode == DecorationMode::ServerSide);
}
- fn unset_mode(&mut self, _toplevel: ToplevelSurface) {}
+ /// The client dropped its decoration-mode preference. `new_decoration`
+ /// already offered `ServerSide` as the default the next configure will
+ /// carry, so mirror that same default here rather than leaving
+ /// whatever mode was negotiated before this - otherwise a client that
+ /// requests `ClientSide`, then later unsets it expecting the default
+ /// back, would stay undecorated by us forever.
+ fn unset_mode(&mut self, toplevel: ToplevelSurface) {
+ toplevel.with_pending_state(|state| {
+ state.decoration_mode = Some(DecorationMode::ServerSide);
+ });
+ toplevel.send_configure();
+ self.set_decorated_from_mode(toplevel.wl_surface(), true);
+ }
}
impl ShmHandler for CompState {
@@ -119,8 +404,167 @@ impl BufferHandler for CompState {
fn buffer_destroyed(&mut self, _buffer: &WlBuffer) {}
}
+impl DmabufHandler for CompState {
+ fn dmabuf_state(&mut self) -> &mut DmabufState {
+ &mut self.dmabuf_state
+ }
+
+ /// Validates a client's dmabuf by actually importing it wherever a
+ /// renderer is reachable from here, so a genuinely bad buffer (wrong
+ /// modifier, format the renderer doesn't support) gets the protocol
+ /// error instead of silently rendering garbage later.
+ ///
+ /// That's only the udev backend: its `PixmanRenderer` lives inside
+ /// `self.udev` (`UdevState`), a field of this same struct.
+ /// `PixmanRenderer` supports dmabuf import despite being a pure
+ /// software renderer - `dmabuf_formats()` only advertises the Linear
+ /// modifier, which it imports by mmap'ing the buffer and reading it
+ /// directly as pixels, no GPU involved. This is what actually answers
+ /// `docs/PANEL_SUPPORT_TODO.md`'s P0.3: GTK4 allocates via its own
+ /// EGL/gbm path against the real DRM render node (untouched by this
+ /// compositor either way) and hands the result here as a Linear-
+ /// modifier dmabuf, which pixman can read straight off.
+ ///
+ /// The winit (nested/dev) backend's `GlesRenderer` lives on
+ /// `WaylandPlatform`, a sibling of `CompState`, not reachable from a
+ /// method on `CompState` itself. Accepted there without eager
+ /// validation - the buffer still gets imported the same way every
+ /// other buffer type already is, lazily, the first time it is actually
+ /// rendered via `render_elements_from_surface_tree`. Real hardware,
+ /// where P0.3 actually bites, always goes through the udev path.
+ fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
+ match self.udev.as_mut() {
+ Some(udev) => match udev.renderer.import_dmabuf(&dmabuf, None) {
+ Ok(_) => {
+ let _ = notifier.successful::<CompState>();
+ }
+ Err(e) => {
+ log::warn!("udev: rejecting dmabuf import: {e}");
+ notifier.failed();
+ }
+ },
+ None => {
+ let _ = notifier.successful::<CompState>();
+ }
+ }
+ }
+}
+
+impl XdgActivationHandler for CompState {
+ fn activation_state(&mut self) -> &mut XdgActivationState {
+ &mut self.xdg_activation_state
+ }
+
+ /// A launcher spawns an app after first getting a token
+ /// (`get_activation_token`) and handing it to the new process (usually
+ /// via `XDG_ACTIVATION_TOKEN`); the app's own first window then
+ /// presents that same token back here via `activate`, asking to be
+ /// raised. Without this, that request was silently ignored - the new
+ /// window opened and just sat there unfocused behind everything,
+ /// exactly the gap `docs/PANEL_SUPPORT_TODO.md`'s P1 flagged.
+ ///
+ /// No token bookkeeping of our own: `token_created`'s default already
+ /// accepts every token (fine for a single-user session with no
+ /// cross-client trust boundary to enforce), so all that's left is
+ /// mapping the activating `surface` to a `WindowId` and reusing the
+ /// exact same `focus_window` path a dock's "activate" request already
+ /// goes through (`foreign_toplevel.rs`). If the surface isn't tracked
+ /// yet - the activation raced ahead of this window's own mapping --
+ /// there is nothing to focus yet, so this is a no-op rather than an
+ /// error; the protocol doesn't require honoring every activation.
+ fn request_activation(&mut self, _token: XdgActivationToken, _token_data: XdgActivationTokenData, surface: WlSurface) {
+ if let Some(&id) = self.surface_to_id.get(&surface) {
+ crate::input::focus_window(self, id);
+ }
+ }
+}
+
+/// `zwp_text_input_manager_v3` + `zwp_input_method_manager_v2`: lets a real
+/// input method (fcitx5, ibus, any CJK/dead-key/emoji-picker IME) attach to
+/// whichever surface has keyboard focus and draw its own candidate/
+/// composition popup. Without these two globals a client that only speaks
+/// text-input (most modern toolkits do, GTK4/Qt6 included) has no way to
+/// tell the compositor "I have an editable text field, here is its cursor
+/// rectangle" - every desktop app's search box, address bar, and chat
+/// input silently loses IME support, not just an edge case.
+///
+/// Focus tracking needs *no* wiring here at all: `CompState::KeyboardFocus`
+/// is a plain `WlSurface`, and smithay's own blanket `impl KeyboardTarget
+/// for WlSurface` already calls `seat.text_input().set_focus/.enter()/
+/// .leave()` and `seat.input_method().activate_input_method()/
+/// deactivate_input_method()` from inside `enter`/`leave` - which
+/// `set_keyboard_focus`'s existing `keyboard.set_focus(...)` call already
+/// triggers on every real focus change. The only things actually missing
+/// were the two manager globals and this handler for the popup surface
+/// lifecycle.
+impl smithay::wayland::input_method::InputMethodHandler for CompState {
+ /// A candidate/composition window (an emoji picker, a CJK candidate
+ /// list) just opened. Tracked as a regular [`PopupKind::InputMethod`]
+ /// in the same [`PopupManager`](smithay::desktop::PopupManager) that
+ /// already owns every `xdg_popup` - `elements::popup_render_elements`
+ /// renders both kinds identically, so no separate render path is
+ /// needed for this to actually become visible.
+ fn new_popup(&mut self, surface: ImePopupSurface) {
+ if let Err(e) = self.popups.track_popup(PopupKind::from(surface)) {
+ log::warn!("input-method: failed to track popup: {e}");
+ }
+ }
+
+ fn dismiss_popup(&mut self, surface: ImePopupSurface) {
+ if let Some(parent) = surface.get_parent().map(|p| p.surface.clone()) {
+ let _ = smithay::desktop::PopupManager::dismiss_popup(&parent, &PopupKind::from(surface));
+ }
+ }
+
+ /// The IME moved its own popup (e.g. following the text cursor as the
+ /// user types) - `PopupSurface::location()` already reflects the new
+ /// position; nothing else needs updating on this side, matching every
+ /// other smithay-based compositor's own no-op here.
+ fn popup_repositioned(&mut self, _surface: ImePopupSurface) {}
+
+ /// Where the IME should anchor its popup, in the parent surface's own
+ /// output-independent (logical, window-relative-origin) space - same
+ /// geometry `elements::popup_targets` already computes for xdg popups,
+ /// reused here rather than duplicated. A window not yet tracked (the
+ /// activation raced ahead of its own mapping) gets a default/zero rect,
+ /// same "no-op rather than an error" stance as `request_activation`
+ /// above.
+ fn parent_geometry(&self, parent: &WlSurface) -> smithay::utils::Rectangle<i32, smithay::utils::Logical> {
+ let Some(&id) = self.surface_to_id.get(parent) else {
+ return smithay::utils::Rectangle::default();
+ };
+ let wm = self.wm.borrow();
+ let Some(w) = wm.window(id) else {
+ return smithay::utils::Rectangle::default();
+ };
+ let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 };
+ smithay::utils::Rectangle::new((w.geometry.x, w.geometry.y + band).into(), (w.geometry.width as i32, w.geometry.height as i32).into())
+ }
+}
+
impl smithay::wayland::output::OutputHandler for CompState {}
+/// `wp_cursor_shape_v1`: lets a client ask for a *named* cursor (text,
+/// grab, resize edges, ...) instead of rendering and attaching its own
+/// surface. Its requests route straight into `SeatHandler::cursor_image`
+/// below, same as a client-drawn cursor surface does - no extra state on
+/// our side. Without this global at all, a client that only speaks this
+/// (increasingly the norm - recent GTK4/Firefox use it for most cursor
+/// changes) has no way to tell us the pointer should look like anything
+/// but whatever it last was, which reads as the cursor going stale, wrong,
+/// or simply disappearing depending on what was showing when the client
+/// gave up trying.
+///
+/// `TabletSeatHandler` is a supertrait bound of this protocol's `Dispatch`
+/// impl (cursor-shape covers tablet tools too); srdwm has no tablet
+/// support to speak of, so every method is left at its no-op default.
+impl TabletSeatHandler for CompState {}
+
+/// Fractional scaling. srdwm runs every output at scale 1, so there is
+/// nothing to compute - but the global has to exist, because clients that
+/// use it (notably wallpaper daemons) treat it as mandatory.
+impl smithay::wayland::fractional_scale::FractionalScaleHandler for CompState {}
+
impl SeatHandler for CompState {
type KeyboardFocus = WlSurface;
type PointerFocus = WlSurface;
@@ -145,6 +589,13 @@ impl WlrLayerShellHandler for CompState {
}
fn new_layer_surface(&mut self, surface: WlrLayerSurface, wl_output: Option<WlOutput>, _layer: Layer, namespace: String) {
+ // Logged before anything else can early-return or panic: the
+ // question this answers (see docs/PANEL_SUPPORT_TODO.md) is
+ // whether this handler is reached AT ALL for a later
+ // `get_layer_surface` request in a create -> commit -> destroy ->
+ // commit-again -> create sequence, or whether the client's
+ // dispatch is already dead by then and this never runs.
+ log::debug!("layer-shell: new_layer_surface entered, surface={:?} namespace={namespace:?} output_named={}", surface.wl_surface().id(), wl_output.is_some());
// A client may name the output it wants (a bar on a specific
// monitor); if it doesn't, or names one we don't drive, it lands on
// the primary output.
@@ -157,14 +608,28 @@ impl WlrLayerShellHandler for CompState {
log::warn!("wayland: layer surface requested but no output exists yet");
return;
};
+ // Paired with the debug log in `ensure_layer_initial_configure`'s
+ // early return - see docs/PANEL_SUPPORT_TODO.md's P0. This is the
+ // other half of "did map_layer actually succeed, and on which
+ // output": logged unconditionally (not just on the error paths
+ // that already existed) so a real reproduction shows both sides of
+ // the handoff instead of just the failure.
+ let surface_id = surface.wl_surface().id();
let layer_surface = DesktopLayerSurface::new(surface, namespace);
let result = layer_map_for_output(&output).map_layer(&layer_surface);
- if let Err(e) = result {
- log::warn!("wayland: failed to map layer surface: {e}");
+ match &result {
+ Ok(()) => log::debug!("layer-shell: mapped surface {surface_id:?} onto output {}", output.name()),
+ Err(e) => log::warn!("wayland: failed to map layer surface {surface_id:?}: {e}"),
}
}
fn layer_destroyed(&mut self, surface: WlrLayerSurface) {
+ // See the matching top-of-function log in `new_layer_surface`.
+ log::debug!("layer-shell: layer_destroyed entered, surface={:?}", surface.wl_surface().id());
+ // Marks this surface for the pre-commit-hook workaround in
+ // `new_surface` - see that function's doc comment for the bug
+ // this exists to route around.
+ self.dead_layer_surfaces.insert(surface.wl_surface().clone());
// The surface belongs to exactly one output's map, but which one is
// the client's choice, so unmap from whichever holds it.
for output in self.outputs().cloned().collect::<Vec<_>>() {
@@ -229,10 +694,44 @@ impl DataControlHandler for CompState {
}
}
+/// `ext_idle_notify_v1`. All the real logic (per-notification timers,
+/// resetting them on activity, honouring inhibition) already lives in
+/// smithay's own `IdleNotifierState` - this is just the getter it needs.
+/// See `input.rs`'s `notify_idle_activity` for the other half: nothing
+/// calls `notify_activity` on its own, that has to happen from every real
+/// input path.
+impl smithay::wayland::idle_notify::IdleNotifierHandler for CompState {
+ fn idle_notifier_state(&mut self) -> &mut smithay::wayland::idle_notify::IdleNotifierState<Self> {
+ &mut self.idle_notifier_state
+ }
+}
+
+/// `zwp_idle_inhibit_manager_v1`. A video player (or anything else that
+/// wants the screen to stay on/unlocked while it runs) creates one of
+/// these tied to its own surface; as long as at least one is alive,
+/// `IdleNotifierState::set_is_inhibited` stops idle timers from firing at
+/// all - see `idle_inhibiting_surfaces`'s doc comment on `CompState` for
+/// the one simplification (not workspace-visibility-aware) this takes.
+impl smithay::wayland::idle_inhibit::IdleInhibitHandler for CompState {
+ fn inhibit(&mut self, surface: WlSurface) {
+ self.idle_inhibiting_surfaces.push(surface);
+ self.idle_notifier_state.set_is_inhibited(true);
+ }
+
+ fn uninhibit(&mut self, surface: WlSurface) {
+ self.idle_inhibiting_surfaces.retain(|s| s != &surface);
+ self.idle_notifier_state.set_is_inhibited(!self.idle_inhibiting_surfaces.is_empty());
+ }
+}
+
delegate_compositor!(CompState);
delegate_xdg_shell!(CompState);
delegate_xdg_decoration!(CompState);
delegate_shm!(CompState);
+delegate_dmabuf!(CompState);
+delegate_xdg_activation!(CompState);
+delegate_text_input_manager!(CompState);
+delegate_input_method_manager!(CompState);
delegate_seat!(CompState);
delegate_output!(CompState);
delegate_layer_shell!(CompState);
@@ -240,3 +739,8 @@ delegate_data_device!(CompState);
delegate_primary_selection!(CompState);
delegate_data_control!(CompState);
delegate_session_lock!(CompState);
+delegate_cursor_shape!(CompState);
+smithay::delegate_viewporter!(CompState);
+smithay::delegate_fractional_scale!(CompState);
+smithay::delegate_idle_notify!(CompState);
+smithay::delegate_idle_inhibit!(CompState);
diff --git a/crates/wayland/src/screencopy.rs b/crates/wayland/src/screencopy.rs
index 324669c..cb632aa 100644
--- a/crates/wayland/src/screencopy.rs
+++ b/crates/wayland/src/screencopy.rs
@@ -21,6 +21,7 @@ use std::time::UNIX_EPOCH;
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::{ExportMem, Renderer};
+use smithay::output::Output;
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
use smithay::reexports::wayland_server::protocol::wl_shm;
@@ -66,6 +67,11 @@ impl ScreencopyState {
pub struct FrameData {
/// Region of the output to capture, in physical pixels.
pub region: Rectangle<i32, Physical>,
+ /// The output this frame captures. `None` if the `wl_output` the client
+ /// named at request time doesn't resolve to a live output (e.g.
+ /// unplugged between bind and capture) - such a frame is failed
+ /// immediately and never queued, so this is only read on that path.
+ pub output: Option<Output>,
/// Set once `copy`/`copy_with_damage` has been handled, so a second one
/// can be rejected with the protocol's `already_used` error.
pub used: bool,
@@ -78,6 +84,11 @@ pub struct PendingCapture {
pub frame: ZwlrScreencopyFrameV1,
pub buffer: WlBuffer,
pub region: Rectangle<i32, Physical>,
+ /// Which head this capture is bound to - the udev backend renders each
+ /// head into its own framebuffer, so a capture must be serviced against
+ /// the framebuffer for *this* output, not whichever head happens to
+ /// render first (see `render_udev_frame`'s per-output split).
+ pub output: Output,
/// `copy_with_damage` clients expect a `damage` event before `ready`.
pub with_damage: bool,
}
@@ -106,9 +117,9 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for CompState {
data_init: &mut DataInit<'_, Self>,
) {
use zwlr_screencopy_manager_v1::Request;
- let (frame, region) = match request {
+ let (frame, region, output) = match request {
Request::CaptureOutput { frame, overlay_cursor: _, output } => {
- (frame, state.output_capture_region(&output))
+ (frame, state.output_capture_region(&output), state.output_for_wl(&output).map(|e| e.output.clone()))
}
Request::CaptureOutputRegion { frame, overlay_cursor: _, output, x, y, width, height } => {
// Clamp to the output: a client is free to ask for a region
@@ -116,15 +127,17 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for CompState {
// `copy_framebuffer` errors out on out-of-bounds reads.
let full = state.output_capture_region(&output);
let requested = Rectangle::new((x, y).into(), (width.max(0), height.max(0)).into());
- (frame, full.intersection(requested).unwrap_or_default())
+ let region = full.intersection(requested).unwrap_or_default();
+ (frame, region, state.output_for_wl(&output).map(|e| e.output.clone()))
}
Request::Destroy => return,
_ => return,
};
- let frame = data_init.init(frame, FrameData { region, used: false });
- if region.size.w <= 0 || region.size.h <= 0 {
- // Nothing to capture (empty or fully off-screen region).
+ let frame = data_init.init(frame, FrameData { region, output: output.clone(), used: false });
+ if region.size.w <= 0 || region.size.h <= 0 || output.is_none() {
+ // Nothing to capture: an empty/off-screen region, or a
+ // `wl_output` that no longer resolves to a live head.
frame.failed();
return;
}
@@ -182,10 +195,20 @@ impl Dispatch<ZwlrScreencopyFrameV1, FrameData> for CompState {
return;
}
+ // A frame with no resolved output was already failed at request
+ // time (see the manager's `request` handler) and should never reach
+ // `Copy`/`CopyWithDamage` from a well-behaved client; guarded rather
+ // than unwrapped so a misbehaving one can't panic the compositor.
+ let Some(output) = data.output.clone() else {
+ frame.post_error(zwlr_screencopy_frame_v1::Error::InvalidBuffer, "frame has no output to capture");
+ return;
+ };
+
state.screencopy_pending.push(PendingCapture {
frame: frame.clone(),
buffer,
region: data.region,
+ output,
with_damage,
});
}
diff --git a/crates/wayland/src/state.rs b/crates/wayland/src/state.rs
index bea3453..1964d99 100644
--- a/crates/wayland/src/state.rs
+++ b/crates/wayland/src/state.rs
@@ -18,7 +18,8 @@ use std::time::{Duration, Instant};
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::memory::MemoryRenderBuffer;
-use smithay::desktop::{layer_map_for_output, Space, Window as DWindow, WindowSurfaceType};
+use smithay::backend::renderer::element::solid::SolidColorBuffer;
+use smithay::desktop::{layer_map_for_output, PopupManager, Space, Window as DWindow, WindowSurfaceType};
use smithay::input::{Seat, SeatState};
use smithay::output::Output;
use smithay::reexports::wayland_server::backend::{ClientData, ClientId, DisconnectReason};
@@ -34,12 +35,14 @@ use smithay::wayland::session_lock::SessionLockManagerState;
use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, LayerSurfaceData, WlrLayerShellState};
use smithay::wayland::shell::xdg::{ToplevelSurface, XdgShellState, XdgToplevelSurfaceData};
use smithay::wayland::shell::xdg::decoration::XdgDecorationState;
+use smithay::wayland::dmabuf::DmabufState;
use smithay::wayland::shm::ShmState;
+use smithay::wayland::xdg_activation::XdgActivationState;
use srdwm_core::{Event as CoreEvent, Window as CoreWindow, WindowId, WindowManager, TITLEBAR_HEIGHT};
use crate::lock::SessionLock;
-use crate::{decoration, screencopy, udev, xwayland};
+use crate::{decoration, foreign_toplevel, gamma_control, output_management, output_power, screencopy, udev, workspace, xwayland};
#[derive(Default)]
pub(crate) struct ClientState {
@@ -91,9 +94,33 @@ pub(crate) struct CompState {
pub(crate) xdg_shell_state: XdgShellState,
pub(crate) _xdg_decoration_state: XdgDecorationState,
pub(crate) shm_state: ShmState,
+ /// `zwp_linux_dmabuf_v1` - see `protocols.rs`'s `DmabufHandler` impl.
+ /// Without this global, no client can hand the compositor a GPU buffer
+ /// at all; GTK4 in particular tries to open a DRM render node to
+ /// allocate one anyway, fails with no global to query, and crashes
+ /// instead of falling back (see `docs/PANEL_SUPPORT_TODO.md`'s P0.3).
+ pub(crate) dmabuf_state: DmabufState,
+ /// `xdg_activation_v1` - see `protocols.rs`'s `XdgActivationHandler`.
+ /// Without this, a launcher's freshly-spawned app opens unfocused
+ /// behind everything: nothing raises it once its window actually maps.
+ pub(crate) xdg_activation_state: XdgActivationState,
+ /// `zwp_text_input_manager_v3` + `zwp_input_method_manager_v2` - lets a
+ /// real IME (fcitx5, ibus) attach to the focused text field and draw
+ /// its own composition/candidate popup. See `protocols.rs`'s
+ /// `InputMethodHandler` impl for why no extra focus-tracking is needed
+ /// beyond registering these two globals.
+ pub(crate) _text_input_manager_state: smithay::wayland::text_input::TextInputManagerState,
+ pub(crate) _input_method_manager_state: smithay::wayland::input_method::InputMethodManagerState,
+ /// `gtk_shell1` - the Wayland-native half of global-menu support. See
+ /// `gtk_shell.rs`'s module doc comment.
+ pub(crate) _gtk_shell_state: crate::gtk_shell::GtkShellState,
pub(crate) seat_state: SeatState<CompState>,
pub(crate) seat: Seat<CompState>,
pub(crate) space: Space<DWindow>,
+ /// Tracks every live `xdg_popup` (position, parent, grabs) - see
+ /// `protocols.rs`'s `new_popup`/`reposition_request`/`commit` and
+ /// `popup_render_elements` below.
+ pub(crate) popups: PopupManager,
/// Every output srdwm drives, left-to-right in the global coordinate
/// space. The winit backend always has exactly one (its nested window);
/// the udev backend has one per connected connector.
@@ -112,10 +139,60 @@ pub(crate) struct CompState {
pub(crate) primary_selection_state: PrimarySelectionState,
pub(crate) data_control_state: DataControlState,
pub(crate) session_lock_state: SessionLockManagerState,
+ /// `wp_viewporter` and `wp_fractional_scale_manager_v1`. Held only to
+ /// keep the globals alive: surface scaling is handled inside smithay,
+ /// and the compositor needs no logic of its own for either.
+ ///
+ /// Not optional in practice - a wallpaper daemon (`awww`/`swww`) hard
+ /// *requires* both and panics on startup without them, and video
+ /// players use viewporter for scaled playback.
+ pub(crate) _viewporter_state: smithay::wayland::viewporter::ViewporterState,
+ pub(crate) _fractional_scale_state: smithay::wayland::fractional_scale::FractionalScaleManagerState,
+ pub(crate) _cursor_shape_state: smithay::wayland::cursor_shape::CursorShapeManagerState,
pub(crate) _screencopy_state: screencopy::ScreencopyState,
/// Captures requested via `wlr-screencopy` but not yet serviced; drained
/// inside the render pass (see `screencopy::service_pending`).
pub(crate) screencopy_pending: Vec<screencopy::PendingCapture>,
+ pub(crate) _foreign_toplevel_state: foreign_toplevel::ForeignToplevelState,
+ /// Every bound `zwlr_foreign_toplevel_manager_v1` (one per dock/switcher
+ /// client), so a newly-created window can be announced to all of them --
+ /// see `foreign_toplevel::window_created`.
+ pub(crate) foreign_toplevel_managers: Vec<wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1>,
+ /// The live `zwlr_foreign_toplevel_handle_v1` objects for each window --
+ /// one per bound manager, since each manager only ever sees the handles
+ /// created for *it*.
+ pub(crate) foreign_toplevel_handles: HashMap<WindowId, Vec<wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1>>,
+ pub(crate) _workspace_state: workspace::WorkspaceManagerState,
+ /// `zwlr_output_power_management_v1` - `None` on the winit (nested)
+ /// backend, which has no real display to power down. See
+ /// `output_power.rs`'s module doc comment.
+ pub(crate) _output_power_state: Option<output_power::OutputPowerManagerState>,
+ /// `zwlr_gamma_control_manager_v1` - `None` on the winit (nested)
+ /// backend, same reasoning as `_output_power_state`. See
+ /// `gamma_control.rs`'s module doc comment.
+ pub(crate) _gamma_control_state: Option<gamma_control::GammaControlManagerState>,
+ /// `zwlr_output_management_v1` - unlike `_output_power_state`/
+ /// `_gamma_control_state` above, not `Option`-gated: enumerating
+ /// outputs and applying position/scale/transform changes works the
+ /// same way (`Output::change_current_state`) on both backends, so
+ /// there's no backend where advertising this global would be
+ /// dishonest. See `output_management.rs`'s module doc comment.
+ pub(crate) _output_management_state: output_management::OutputManagementState,
+ pub(crate) output_managers: Vec<wayland_protocols_wlr::output_management::v1::server::zwlr_output_manager_v1::ZwlrOutputManagerV1>,
+ pub(crate) output_heads: HashMap<String, Vec<wayland_protocols_wlr::output_management::v1::server::zwlr_output_head_v1::ZwlrOutputHeadV1>>,
+ pub(crate) output_modes: HashMap<String, Vec<wayland_protocols_wlr::output_management::v1::server::zwlr_output_mode_v1::ZwlrOutputModeV1>>,
+ /// Bumped on every real output-state change; sent with `zwlr_output_
+ /// manager_v1.done` and checked against a `create_configuration`
+ /// request's own serial so a client configuring against stale
+ /// (pre-hotplug) state gets `cancelled` rather than silently
+ /// clobbering whatever changed after it last saw a `done`.
+ pub(crate) output_serial: u32,
+ /// What was last broadcast to output-management clients - see
+ /// `output_management::broadcast_dirty_outputs`'s doc comment.
+ pub(crate) last_broadcast_outputs: Vec<output_management::OutputSnapshot>,
+ pub(crate) workspace_managers: Vec<wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::ExtWorkspaceManagerV1>,
+ pub(crate) workspace_groups: Vec<wayland_protocols::ext::workspace::v1::server::ext_workspace_group_handle_v1::ExtWorkspaceGroupHandleV1>,
+ pub(crate) workspace_handles: HashMap<srdwm_core::WorkspaceId, Vec<wayland_protocols::ext::workspace::v1::server::ext_workspace_handle_v1::ExtWorkspaceHandleV1>>,
/// Session lock (`ext-session-lock-v1`). While `locked` is set, client
/// content is never rendered and input never reaches normal clients --
/// see `SessionLockHandler` below.
@@ -124,15 +201,63 @@ pub(crate) struct CompState {
/// the default when no client has said). See `cursor.rs` for why this
/// has to be drawn by us on the DRM backend.
pub(crate) cursor_status: smithay::input::pointer::CursorImageStatus,
- /// Bitmap for the built-in arrow, built once at startup rather than
- /// per frame.
- pub(crate) cursor_buffer: MemoryRenderBuffer,
+ /// Built-in cursor bitmaps (arrow, text, resize directions), built once
+ /// at startup rather than per frame.
+ pub(crate) cursor_buffers: crate::cursor::CursorBuffers,
/// Last titlebar press, for double-click detection.
pub(crate) last_titlebar_click: Option<(WindowId, u32)>,
+ /// The right-click titlebar window menu, if one is currently open --
+ /// see `context_menu.rs`. `None` almost always; a click anywhere while
+ /// `Some` resolves (selects a row) or dismisses it, never falls
+ /// through to normal click handling underneath.
+ pub(crate) context_menu: Option<crate::context_menu::ContextMenu>,
+ /// Rasterised pixels for the currently-open `context_menu`, rebuilt
+ /// once when it opens - same cached-until-something-changes pattern
+ /// `decorations`/`border_top_decorations` already use, not rebuilt
+ /// per frame.
+ pub(crate) context_menu_buffer: Option<MemoryRenderBuffer>,
pub(crate) wm: Rc<RefCell<WindowManager>>,
pub(crate) surface_to_id: HashMap<WlSurface, WindowId>,
pub(crate) id_to_window: HashMap<WindowId, DWindow>,
+ /// Surfaces whose `zwlr_layer_surface_v1` role has been destroyed --
+ /// consulted by the pre-commit hook `CompositorHandler::new_surface`
+ /// registers (see its doc comment) to work around a real smithay bug
+ /// where a later commit of one of these surfaces gets spuriously
+ /// rejected. Entries are added in `layer_destroyed`; there's
+ /// deliberately no removal, since a `WlSurface` here is meaningless
+ /// after the client destroys it too and Rust never reuses the id while
+ /// any handle (including this one) still exists.
+ pub(crate) dead_layer_surfaces: HashSet<WlSurface>,
pub(crate) decorations: HashMap<WindowId, MemoryRenderBuffer>,
+ /// The top border strip's rounded-corner bitmap, cached the same way
+ /// and at the same trigger points as `decorations` (built in
+ /// `redraw_decoration_buffer`) - see that method's doc comment for why
+ /// this has to be rebuilt at the same points a titlebar is, and
+ /// `elements::border_side_render_element`'s doc comment for the damage-
+ /// tracking reason a per-frame rebuild was wrong in the first place.
+ pub(crate) border_top_decorations: HashMap<WindowId, MemoryRenderBuffer>,
+ /// Persistent solid-colour buffers backing a window's other three
+ /// border strips (bottom, left, right - `decoration::border_strips`'
+ /// order past index 0), reused by position every frame rather than
+ /// rebuilt - see `elements::border_side_render_element`'s doc comment.
+ /// A flat pool rather than a fixed `[_; 3]`, one buffer per rendered
+ /// *fragment* rather than per strip: a strip occluded by a window
+ /// stacked in front of it is split into however many visible pieces
+ /// remain (see `elements::visible_border_fragments`), so the count
+ /// needed varies frame to frame as windows move. Never shrunk once
+ /// grown - a few idle unused buffers cost nothing meaningful, and
+ /// dropping them would lose the damage-tracking stability the whole
+ /// scheme exists for the moment fragment counts fluctuate back up.
+ pub(crate) border_side_buffers: HashMap<WindowId, Vec<SolidColorBuffer>>,
+ /// Client-visible size (`geometry` minus the titlebar band) last sent to
+ /// each window via `xdg_toplevel.configure`. `sync_geometry` runs on
+ /// every pointer-motion tick while a window is being dragged or resized
+ /// (see `input::handle_pointer_position`); a plain move changes only
+ /// position, not size, so without this it was re-sending a configure
+ /// and re-rasterizing the titlebar's text from scratch on every single
+ /// motion event of every drag, which is what made moving a window
+ /// stutter. Only a real size change now does either.
+ pub(crate) last_synced_size: HashMap<WindowId, (i32, i32)>,
pub(crate) pending: Rc<RefCell<Vec<CoreEvent>>>,
pub(crate) bound_keys: Rc<HashSet<String>>,
/// Combos that repeat while held (`srd.bind_repeat`).
@@ -151,8 +276,100 @@ pub(crate) struct CompState {
/// Mapped X11 windows still waiting for XWayland to associate a
/// `wl_surface` - see `xwayland.rs` and `commit()` above.
pub(crate) xwayland_pending: Vec<smithay::xwayland::X11Surface>,
+ /// A second, independent connection to the XWayland X server, used to
+ /// keep `_NET_ACTIVE_WINDOW`/`_NET_CLIENT_LIST`/`_NET_CLIENT_LIST_STACKING`
+ /// on the root window up to date - see `xwayland::EwmhState` and its
+ /// module docs for why this needs its own connection rather than going
+ /// through `X11Wm`. `None` until XWayland is ready, same as `xwm`.
+ pub(crate) ewmh: Option<xwayland::EwmhState>,
+ /// `ext_idle_notify_v1` - lets a client (a lock daemon, a bar's idle
+ /// indicator) ask to be told after N seconds of no real input. Both
+ /// this and `_idle_inhibit_manager_state` below use smithay's own
+ /// complete built-in modules (`wayland::idle_notify`/`idle_inhibit`),
+ /// unlike every other hand-written protocol in this crate - neither
+ /// had a raw-XML precedent to follow since smithay already ships full
+ /// working implementations of both.
+ pub(crate) idle_notifier_state: smithay::wayland::idle_notify::IdleNotifierState<CompState>,
+ pub(crate) _idle_inhibit_manager_state: smithay::wayland::idle_inhibit::IdleInhibitManagerState,
+ /// Surfaces currently holding a live `zwp_idle_inhibitor_v1` (a video
+ /// player's "keep the screen on while playing" request) - tracked so
+ /// `uninhibit`/`toplevel_destroyed`/`remove_window` can tell whether any
+ /// inhibitor is still alive after one goes away. Deliberately not
+ /// workspace-visibility-aware (an inhibiting window on a workspace
+ /// you've switched away from still keeps the system awake) - a real,
+ /// smaller gap, but matching how several other real compositors treat
+ /// this in practice, and far simpler than threading a re-check through
+ /// every workspace-switch/minimize call site for a video-player-only
+ /// protocol most sessions have at most one client using at a time.
+ pub(crate) idle_inhibiting_surfaces: Vec<WlSurface>,
+ /// Throttles `input::notify_idle_activity` - see its own doc comment
+ /// for why pointer motion (a genuinely high-frequency event, and the
+ /// event this session's earlier per-motion diagnostic-logging
+ /// regression already proved is worth being careful around) needs one.
+ pub(crate) last_idle_notify: Option<Instant>,
+ /// Windows currently mid-tween - see `WindowAnim` and `sync_geometry`'s
+ /// `anim_from` handling. Driven forward once per frame by
+ /// `tick_animations`, called from both backends' poll loops.
+ pub(crate) window_anims: HashMap<WindowId, WindowAnim>,
+ /// Last (maximized, minimized, fullscreen) broadcast to
+ /// `zwlr_foreign_toplevel_handle_v1` listeners for each window - see
+ /// `foreign_toplevel::broadcast_dirty_state`'s doc comment for why this
+ /// exists alongside that module's own immediate `send_state` calls.
+ pub(crate) last_broadcast_flags: HashMap<WindowId, (bool, bool, bool)>,
+ /// Last workspace id broadcast as active to `ext_workspace_v1`
+ /// listeners - see `workspace::broadcast_dirty_active`'s doc comment.
+ pub(crate) last_broadcast_workspace: Option<srdwm_core::WorkspaceId>,
}
+/// An in-flight geometry tween for one window, driven by `tick_animations`.
+///
+/// Deliberately geometry-only (no alpha/scale-of-content): content is
+/// composited through `self.space` like every other window (see
+/// `resync_stacking_order`'s doc comment for why that path was chosen over
+/// per-window custom render elements), which has no per-element opacity or
+/// scale knob to animate independently of the rest of the output. What
+/// *can* animate through `self.space` alone is exactly what interactive
+/// drag/resize already proves out every frame: a `Window.geometry` change
+/// applied via repeated `map_element`/`xdg_toplevel.configure` calls. This
+/// reuses that same, already-live mechanism at a fixed frame rate instead
+/// of on pointer motion.
+pub(crate) struct WindowAnim {
+ pub(crate) from: srdwm_core::Rect,
+ pub(crate) to: srdwm_core::Rect,
+ pub(crate) start: Instant,
+ pub(crate) duration: Duration,
+}
+
+impl WindowAnim {
+ /// Eased (ease-out-cubic) interpolation between `from` and `to`; past
+ /// `duration` this returns `to` exactly, so a caller that keeps polling
+ /// after completion never overshoots.
+ pub(crate) fn current_rect(&self) -> srdwm_core::Rect {
+ let t = (self.start.elapsed().as_secs_f64() / self.duration.as_secs_f64().max(0.001)).min(1.0);
+ let eased = 1.0 - (1.0 - t).powi(3);
+ let lerp = |a: i32, b: i32| a + ((b - a) as f64 * eased).round() as i32;
+ let lerp_u = |a: u32, b: u32| (a as i64 + ((b as i64 - a as i64) as f64 * eased).round() as i64).max(0) as u32;
+ srdwm_core::Rect {
+ x: lerp(self.from.x, self.to.x),
+ y: lerp(self.from.y, self.to.y),
+ width: lerp_u(self.from.width, self.to.width),
+ height: lerp_u(self.from.height, self.to.height),
+ }
+ }
+
+ pub(crate) fn is_done(&self) -> bool {
+ self.start.elapsed() >= self.duration
+ }
+}
+
+/// How far below its resting position a newly-opened window starts before
+/// sliding up into place, in logical pixels. Deliberately a pure position
+/// offset with no size change (see `WindowAnim`'s doc comment on why a
+/// resize tween is reserved for maximize/fullscreen, where the client is
+/// already live and redrawing, not for a window whose first paint may not
+/// have arrived yet).
+const OPEN_SLIDE_OFFSET: i32 = 24;
+
/// A held keybinding that is firing repeatedly.
///
/// Driven from the poll loop rather than a timer source: the winit backend
@@ -167,9 +384,24 @@ pub(crate) struct RepeatState {
pub(crate) next_fire: Instant,
}
-/// Matches the seat's own repeat settings (`add_keyboard(.., 200, 25)`), so
+/// Matches the seat's own repeat settings (`add_keyboard(.., 600, 25)`), so
/// held bindings feel the same as held keys in a text field.
-const REPEAT_DELAY: Duration = Duration::from_millis(200);
+///
+/// 600ms, not smithay's own 200ms stock example value this used to copy --
+/// found comparing against Hyprland's default (`repeat_delay = 600`) after
+/// a live report that typing felt "too sensitive" compared to other
+/// compositors on the same hardware. 200ms is short enough that a key held
+/// even slightly past a fifth of a second - well within normal variance in
+/// how long a real keystroke's finger-down/finger-up dwell actually is, let
+/// alone under any momentary scheduling hiccup delaying when the release
+/// gets processed - starts a client-side repeat and inserts an unintended
+/// extra character, which reads indistinguishably from "double-typing".
+/// This is entirely a client-side effect (repeat_info is sent once and the
+/// client manages its own timer from then on, never re-driven by the
+/// compositor per keystroke - see `crates/wayland/src/input.rs`'s
+/// `handle_keyboard_key_event`), so it was never visible to the diagnostic
+/// logging used earlier to rule out server-side event duplication.
+const REPEAT_DELAY: Duration = Duration::from_millis(600);
const REPEAT_INTERVAL: Duration = Duration::from_millis(1000 / 25);
impl CompState {
@@ -208,11 +440,27 @@ impl CompState {
}
}
-/// Titlebar background is the same regardless of focus (matching the X11
-/// backend); only the title text color changes.
-const TITLEBAR_BG: (u8, u8, u8) = (0x2e, 0x34, 0x40);
-const TITLEBAR_FG_FOCUSED: (u8, u8, u8) = (0x88, 0xc0, 0xd0);
-const TITLEBAR_FG_UNFOCUSED: (u8, u8, u8) = (0x4c, 0x56, 0x6a);
+
+/// The border (added this session, see `decoration::border_strips`) is a
+/// much bigger, more obvious visual element than the titlebar's text
+/// color, so this is what actually answers "which window is focused" --
+/// reported live as genuinely unanswerable, since `Window.border_color` is
+/// a single fixed color with no focus distinction at all, applied
+/// identically to every window regardless of focus.
+///
+/// Dims the window's own configured colour toward gray rather than
+/// replacing it outright with one fixed "unfocused" colour: per-window
+/// `border_color` is a real, used feature (rules set distinct colours per
+/// app), and dimming keeps that distinction visible at a glance while
+/// still making focus unambiguous.
+pub(crate) fn effective_border_color(configured: (u8, u8, u8), focused: bool) -> (u8, u8, u8) {
+ if focused {
+ return configured;
+ }
+ const DIM: f32 = 0.35;
+ let dim = |c: u8| (c as f32 * DIM) as u8;
+ (dim(configured.0), dim(configured.1), dim(configured.2))
+}
/// Output lookup. Everything that used to reach for a single
/// `CompState::output` goes through one of these, so adding outputs did not
@@ -255,23 +503,34 @@ impl CompState {
let id = {
let mut wm = self.wm.borrow_mut();
let id = wm.alloc_window_id();
- let title = with_toplevel_title(&toplevel).unwrap_or_default();
+ let title = with_toplevel_title(toplevel.wl_surface()).unwrap_or_default();
let mut w = CoreWindow::new(id, title);
+ w.app_id = with_toplevel_app_id(toplevel.wl_surface()).unwrap_or_default();
w.geometry = srdwm_core::Rect::new(0, 0, 800, 600 + TITLEBAR_HEIGHT as i32 as u32);
wm.add_window(w);
+ // Starts the open-slide tween (see `WindowAnim`'s doc comment):
+ // the window's first `sync_geometry` call below will see this,
+ // register the tween, and place it here - a few pixels below
+ // its resting position - rather than jumping straight to
+ // `geometry`. Same size throughout, so no extra client configure
+ // is needed for the tween itself.
+ if wm.animations_enabled {
+ if let Some(win) = wm.window_mut(id) {
+ let g = win.geometry;
+ win.anim_from = Some(srdwm_core::Rect { y: g.y + OPEN_SLIDE_OFFSET, ..g });
+ }
+ }
id
};
- let geom = self.wm.borrow().window(id).map(|w| w.geometry).unwrap_or_default();
let dwindow = DWindow::new_wayland_window(toplevel.clone());
- toplevel.with_pending_state(|state| {
- state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into());
- });
- toplevel.send_configure();
-
- self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true);
self.surface_to_id.insert(surface.clone(), id);
self.id_to_window.insert(id, dwindow);
+ // `sync_geometry` handles the initial placement itself (map_element
+ // + the first configure, since `last_synced_size` has no entry yet
+ // for this id) as well as starting the open-slide tween registered
+ // above - see its own doc comment.
+ self.sync_geometry(id);
self.redraw_decoration_buffer(id);
// `WindowManager::add_window` already made this the focused window in
// srdwm's own state, but that alone is purely internal bookkeeping --
@@ -283,6 +542,26 @@ impl CompState {
// A newly-mapped window goes on top, but not over a pinned one.
self.raise_pinned();
self.pending.borrow_mut().push(CoreEvent::WindowCreated(id));
+ foreign_toplevel::window_created(self, id);
+ }
+
+ /// Applies a negotiated `zxdg_toplevel_decoration_v1` mode to our own
+ /// `Window.decorated` flag and refreshes (or drops) its titlebar buffer
+ /// to match - see `XdgDecorationHandler::request_mode`'s doc comment
+ /// for why. A no-op if the surface has no window yet (decoration
+ /// negotiation racing ahead of `new_toplevel`, which shouldn't happen
+ /// in practice but costs nothing to guard against).
+ pub(crate) fn set_decorated_from_mode(&mut self, surface: &WlSurface, decorated: bool) {
+ let Some(&id) = self.surface_to_id.get(surface) else { return };
+ if let Some(w) = self.wm.borrow_mut().window_mut(id) {
+ w.decorated = decorated;
+ }
+ self.redraw_decoration_buffer(id);
+ // Re-applies content size/position for the now-changed titlebar
+ // reservation - see `sync_geometry`'s own doc comment on why this
+ // can't be skipped: redrawing the titlebar buffer alone doesn't
+ // touch the content area's size or offset at all.
+ self.sync_geometry(id);
}
/// (Re)renders the titlebar band for `id` - background plus title text
@@ -291,16 +570,46 @@ impl CompState {
/// affects layout), and focus change (text color).
pub(crate) fn redraw_decoration_buffer(&mut self, id: WindowId) {
let Some(w) = self.wm.borrow().window(id).cloned() else { return };
- if !w.decorated {
+ let focused = self.wm.borrow().focused_id() == Some(id);
+ let theme = self.wm.borrow().theme;
+ if w.decorated {
+ let fg = if focused { theme.titlebar_fg_focused } else { theme.titlebar_fg_unfocused };
+ let width = w.geometry.width.max(1);
+ // Always rounded now, bordered or not - `render_border_top`
+ // gives a bordered window's border strip the matching rounded
+ // cut, so there's no more square-frame-around-a-round-titlebar
+ // clash to avoid. See `render_titlebar`'s `round_corners` doc
+ // comment.
+ let data = decoration::render_titlebar(width, TITLEBAR_HEIGHT, &w.title, theme.titlebar_bg, fg, true);
+ let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None);
+ self.decorations.insert(id, buffer);
+ } else {
self.decorations.remove(&id);
- return;
}
- let focused = self.wm.borrow().focused_id() == Some(id);
- let fg = if focused { TITLEBAR_FG_FOCUSED } else { TITLEBAR_FG_UNFOCUSED };
- let width = w.geometry.width.max(1);
- let data = decoration::render_titlebar(width, TITLEBAR_HEIGHT, &w.title, TITLEBAR_BG, fg);
- let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None);
- self.decorations.insert(id, buffer);
+ // The border-top bitmap is independent of `decorated` - an
+ // undecorated (CSD) window can still have `border_width > 0` - so
+ // it's rebuilt here unconditionally rather than falling under the
+ // early return above. Cached the same way `decorations` is, at the
+ // same trigger points (creation, a size change, a rule re-applying,
+ // and - since this call is now also reached from focus changes --
+ // `w.border_color`'s focused/unfocused dimming): see `elements::
+ // border_side_render_element`'s doc comment for why re-rasterizing
+ // this every render frame (an earlier version of this method did)
+ // was a real, continuous cost, not just a redundant one.
+ if w.border_width > 0 {
+ let color = effective_border_color(w.border_color, focused);
+ let strips = decoration::border_strips(w.geometry, w.border_width);
+ if strips[0].width > 0 && strips[0].height > 0 {
+ let data = decoration::render_border_top(strips[0].width, w.border_width, color);
+ let buffer =
+ MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[0].width as i32, w.border_width as i32), 1, Transform::Normal, None);
+ self.border_top_decorations.insert(id, buffer);
+ } else {
+ self.border_top_decorations.remove(&id);
+ }
+ } else {
+ self.border_top_decorations.remove(&id);
+ }
}
pub(crate) fn remove_window(&mut self, surface: &WlSurface) {
@@ -309,8 +618,37 @@ impl CompState {
self.space.unmap_elem(&w);
}
self.decorations.remove(&id);
+ self.border_top_decorations.remove(&id);
+ self.border_side_buffers.remove(&id);
+ self.last_synced_size.remove(&id);
+ // A window closing (crash, kill, or its own menu's "Close" action
+ // racing ahead of this) while its context menu is still open would
+ // otherwise leave the menu pointing at a dead id - selecting any
+ // row on it would then silently no-op against a window that no
+ // longer exists, with no indication anything went wrong.
+ if self.context_menu.as_ref().is_some_and(|m| m.window == id) {
+ self.close_context_menu();
+ }
self.wm.borrow_mut().remove_window(id);
self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id));
+ foreign_toplevel::window_closed(self, id);
+ // `remove_window` may have picked a new focused window on its own
+ // (falls back to whatever's now on top) - see `sync_keyboard_focus`'s
+ // doc comment for why the Wayland/X11 side needs a separate nudge to
+ // actually catch up to that.
+ crate::input::sync_keyboard_focus(self);
+ // Safety net for `zwp_idle_inhibit_manager_v1`: smithay's own
+ // `IdleInhibitorState` only calls `uninhibit` on an explicit
+ // `destroy` request, never on `Dispatch::destroyed` - so a video
+ // player that crashes or gets killed instead of exiting cleanly
+ // would leave its inhibitor permanently stuck, holding the whole
+ // system awake forever with no client left to ever release it.
+ // Its window closing is the one thing guaranteed to happen either
+ // way, so this is what actually catches that case.
+ if self.idle_inhibiting_surfaces.contains(surface) {
+ self.idle_inhibiting_surfaces.retain(|s| s != surface);
+ self.idle_notifier_state.set_is_inhibited(!self.idle_inhibiting_surfaces.is_empty());
+ }
}
/// Layer surfaces need a configure sent in direct response to their
@@ -322,6 +660,21 @@ impl CompState {
/// its `keyboard_interactivity` isn't reliably known until the client's
/// state has actually committed.
pub(crate) fn ensure_layer_initial_configure(&mut self, surface: &WlSurface) {
+ // Called unconditionally from `commit()` for every surface in the
+ // whole desktop, on every single commit - so before doing anything
+ // that scales with output/layer count, a cheap O(1) check: has this
+ // surface ever gone through `get_layer_surface` at all? Only that
+ // request ever inserts `LayerSurfaceData` into a surface's
+ // `data_map` (smithay's own `handlers.rs`), so this is `None` for
+ // every ordinary xdg-toplevel/subsurface commit - the overwhelming
+ // majority of commits on any real desktop. Skipping straight past
+ // the per-output `layer_for_surface` surface-tree walk for all of
+ // those is the difference between this function costing something
+ // on every single frame any window renders versus only on commits
+ // from the handful of surfaces that were ever layer surfaces.
+ if with_states(surface, |states| states.data_map.get::<LayerSurfaceData>().is_none()) {
+ return;
+ }
// A layer surface lives in exactly one output's `LayerMap` (whichever
// one `new_layer_surface` mapped it into), so find that output rather
// than assuming a single global one.
@@ -329,7 +682,18 @@ impl CompState {
let layer = layer_map_for_output(output).layer_for_surface(surface, WindowSurfaceType::TOPLEVEL).cloned();
layer.map(|l| (output.clone(), l))
});
- let Some((output, layer)) = found else { return };
+ // Not a layer surface (or a destroyed one - see `new_surface`'s
+ // pre-commit-hook workaround, which is what stops this from being
+ // a protocol error). Every ordinary commit from every window in
+ // the desktop passes through here and takes this branch, so this
+ // used to log unconditionally during the P0 investigation
+ // (docs/PANEL_SUPPORT_TODO.md) - diagnostic purpose long since
+ // served, and left running it logged upwards of 15k lines in a few
+ // minutes of normal use (every Firefox frame, every terminal
+ // redraw, ...), which is real wasted I/O, not just noise.
+ let Some((output, layer)) = found else {
+ return;
+ };
// Recompute geometry from whatever the client just committed
// (`set_size`/`set_anchor`/`set_margin`/`set_exclusive_zone` are all
@@ -343,7 +707,27 @@ impl CompState {
// changes; `arrange()` only actually sends a configure when
// something changed, so this is a no-op on a commit that didn't
// touch layer-shell state.
+ let zone_before = layer_map_for_output(&output).non_exclusive_zone();
layer_map_for_output(&output).arrange();
+ let zone_after = layer_map_for_output(&output).non_exclusive_zone();
+ if zone_after != zone_before {
+ // A bar/dock claiming (or releasing) an exclusive zone changes
+ // the area core's placement/tiling should actually use --
+ // without this, `WindowManager`'s notion of the monitor rect
+ // is whatever `Platform::monitors()` returned once at startup
+ // (before any layer-shell client had connected and set a real
+ // exclusive zone), so every window keeps being placed across
+ // the *whole* output including the strip a bar now occupies:
+ // new windows spawn with their titlebar directly under the bar,
+ // rendered beneath it and unreachable to drag. Reusing
+ // `MonitorAdded` here rather than a new event type: main.rs's
+ // handler for it already re-queries the full monitor list from
+ // the platform rather than trusting the event's payload (see
+ // its own comment on why), which is exactly "go recompute the
+ // usable area" - the placeholder `Monitor` below is discarded
+ // unread on that path.
+ self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0))));
+ }
let initial_configure_sent = with_states(surface, |states| {
states
@@ -354,6 +738,7 @@ impl CompState {
});
if !initial_configure_sent {
layer.layer_surface().send_configure();
+ log::debug!("layer-shell: sent initial configure for surface {:?}", surface.id());
}
// Checked on every commit, not just the first: a client can flip
@@ -392,16 +777,121 @@ impl CompState {
}
}
let Some(keyboard) = self.seat.get_keyboard() else { return };
- if keyboard.current_focus() == surface {
+ let old_focus = keyboard.current_focus();
+ if old_focus == surface {
return;
}
let client = surface.as_ref().and_then(|s| self.dh.get_client(s.id()).ok());
set_data_device_focus(&self.dh.clone(), &self.seat.clone(), client.clone());
set_primary_focus(&self.dh.clone(), &self.seat.clone(), client);
+ self.update_net_active_window(surface.as_ref());
+ foreign_toplevel::update_activated(self, old_focus.clone(), surface.as_ref());
+ // `xdg_toplevel`'s own `Activated` state - never sent anywhere
+ // before this. `keyboard.set_focus` below only delivers
+ // `wl_keyboard.enter`/`leave`; it says nothing about `xdg_toplevel`
+ // state, which is the signal GTK4/libadwaita's `:backdrop` CSS
+ // pseudo-class (and equivalents elsewhere) actually key off to
+ // decide whether to paint their *own* titlebar as focused. Without
+ // this, no window - including the only one open, with nothing else
+ // it could be losing focus to - ever received it, so any client
+ // that draws its own focus indicator this way looked permanently
+ // unfocused no matter how many other windows existed, even though
+ // real keyboard input (`wl_keyboard.enter`/`keyboard.set_focus`
+ // below) was unaffected and reached the right window regardless.
+ self.set_window_activated(old_focus.as_ref(), false);
+ self.set_window_activated(surface.as_ref(), true);
let serial = SERIAL_COUNTER.next_serial();
keyboard.set_focus(self, surface, serial);
}
+ /// Sets `xdg_toplevel`'s `Activated` state (or the X11 equivalent) for
+ /// whichever window owns `surface`, flushing a configure for the native
+ /// case - `DWindow::set_activated` alone only queues the pending state
+ /// for an xdg-shell window; nothing sends it to the client without an
+ /// explicit `send_configure` (the X11 case has no such split, its own
+ /// `set_activated` talks to the X connection directly). A no-op if
+ /// `surface` has no window (e.g. `None`, or a layer surface/popup,
+ /// neither of which are `xdg_toplevel`s) or the state didn't actually
+ /// change.
+ fn set_window_activated(&mut self, surface: Option<&WlSurface>, active: bool) {
+ let Some(id) = surface.and_then(|s| self.surface_to_id.get(s)).copied() else { return };
+ let changed = match self.id_to_window.get(&id) {
+ Some(w) if w.set_activated(active) => {
+ if let Some(toplevel) = w.toplevel() {
+ toplevel.send_configure();
+ }
+ true
+ }
+ _ => false,
+ };
+ // Titlebar text and border colour both dim/brighten on focus (see
+ // `redraw_decoration_buffer`'s `fg`/`effective_border_color`), but
+ // neither was ever actually re-rasterized here before - this call
+ // only ever ran for other reasons (creation, a resize, a rule
+ // re-applying) that happened to also be roughly when focus
+ // changed, in practice, close enough that the gap went unnoticed.
+ // Gated on `changed` so a redundant `set_window_activated(_, false)`
+ // for a surface that was never activated (the common `old_focus ==
+ // None` case) doesn't force a pointless redraw.
+ if changed {
+ self.redraw_decoration_buffer(id);
+ }
+ }
+
+ /// Opens the right-click titlebar window menu for `window`, top-left
+ /// corner at `pos` (global space, wherever the click landed). Rebuilds
+ /// and caches the rasterised buffer once here rather than per frame --
+ /// same reasoning as `redraw_decoration_buffer`.
+ pub(crate) fn open_context_menu(&mut self, window: WindowId, pos: (i32, i32)) {
+ let Some(menu) = ({
+ let wm = self.wm.borrow();
+ crate::context_menu::ContextMenu::open(&wm, window, pos)
+ }) else {
+ return;
+ };
+ let theme = self.wm.borrow().theme;
+ let items: Vec<(&str, bool)> = menu.items.iter().map(|&(label, _)| (label, false)).collect();
+ let data = decoration::render_context_menu(menu.width, menu.row_height, &items, theme.titlebar_bg, theme.titlebar_fg_focused, theme.titlebar_fg_unfocused, theme.default_border_color);
+ let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (menu.width as i32, menu.height()), 1, Transform::Normal, None);
+ self.context_menu_buffer = Some(buffer);
+ self.context_menu = Some(menu);
+ }
+
+ pub(crate) fn close_context_menu(&mut self) {
+ self.context_menu = None;
+ self.context_menu_buffer = None;
+ }
+
+ /// Runs whichever action a click on `row` of the currently-open context
+ /// menu selected. Takes the menu's `window`/action rather than reading
+ /// `self.context_menu` itself so the caller can close the menu first
+ /// (clearing the borrow) before this runs - several of these actions
+ /// (`sync_geometry`, `redraw_decoration_buffer`) need `&mut self` in
+ /// ways that would otherwise conflict with an active `self.context_menu`
+ /// borrow.
+ pub(crate) fn run_context_menu_action(&mut self, window: WindowId, action: crate::context_menu::MenuAction) {
+ use crate::context_menu::MenuAction;
+ match action {
+ MenuAction::Minimize => {
+ self.wm.borrow_mut().minimize_window(window);
+ foreign_toplevel::send_state(self, window);
+ }
+ MenuAction::ToggleMaximize => {
+ self.wm.borrow_mut().toggle_maximize(window);
+ self.sync_geometry(window);
+ foreign_toplevel::send_state(self, window);
+ }
+ MenuAction::ToggleAlwaysOnTop => {
+ self.wm.borrow_mut().toggle_always_on_top(window);
+ }
+ MenuAction::Close => {
+ if let Some(w) = self.id_to_window.get(&window) {
+ crate::input::close_dwindow(w);
+ }
+ }
+ }
+ }
+
/// True when this titlebar press is the second of a double-click on the
/// same window. Threshold is the usual 400ms.
pub(crate) fn is_double_click(&mut self, id: WindowId, time: u32) -> bool {
@@ -432,24 +922,296 @@ impl CompState {
}
pub(crate) fn sync_geometry(&mut self, id: WindowId) {
- let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { return };
+ // A pending `anim_from` (set by `toggle_maximize`/`toggle_fullscreen`,
+ // or by `new_managed_window` for the open-slide) means the target
+ // geometry below is where this window is *headed*, not where it
+ // should appear right now - register (or replace) a tween and use
+ // `WindowAnim::current_rect` in its place for this call and every
+ // `tick_animations` call afterward, until it completes. `take()`
+ // both reads and clears it, so a later, non-animated `sync_geometry`
+ // call for the same window (an ordinary drag/resize frame) goes
+ // straight back to applying `geometry` immediately, as before.
+ let anim_from = self.wm.borrow_mut().window_mut(id).and_then(|w| w.anim_from.take());
+ let Some((target, decorated)) = self.wm.borrow().window(id).map(|w| (w.geometry, w.decorated)) else { return };
+ if let Some(from) = anim_from {
+ let duration_ms = self.wm.borrow().animation_duration_ms;
+ if from != target && duration_ms > 0 {
+ self.window_anims
+ .insert(id, WindowAnim { from, to: target, start: Instant::now(), duration: Duration::from_millis(duration_ms as u64) });
+ }
+ }
+ let geom = self.window_anims.get(&id).map(WindowAnim::current_rect).unwrap_or(target);
+ // The titlebar band is only actually reserved when there is one --
+ // an undecorated window (client-side decoration, see
+ // `set_decorated_from_mode`) gets the whole of `geom` as content,
+ // not `geom` minus a band that's no longer being drawn. Without
+ // this, a window that negotiated client-side decoration kept the
+ // same 30px gap at its top anyway: our titlebar wasn't drawn there
+ // (correctly), but the content was still offset down and told it
+ // was 30px shorter than the window actually is, leaving a blank
+ // strip and the frame sitting visibly wrong relative to what's
+ // inside it.
+ let band = if decorated { TITLEBAR_HEIGHT as i32 } else { 0 };
+ // Position always moves with the pointer; only a size change needs a
+ // client configure or a titlebar re-render (see `last_synced_size`'s
+ // doc comment).
+ let size = (geom.width as i32, geom.height as i32 - band);
+ let size_changed = self.last_synced_size.insert(id, size) != Some(size);
+ let mut moved = false;
if let Some(w) = self.id_to_window.get(&id) {
- self.space.map_element(w.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), false);
+ self.space.map_element(w.clone(), (geom.x, geom.y + band), false);
+ moved = true;
if let Some(top) = w.toplevel() {
- top.with_pending_state(|state| {
- state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into());
- });
- top.send_configure();
+ // xdg-shell position is a purely compositor-side concept --
+ // the client is never told it - so only a size change
+ // needs a configure here.
+ if size_changed {
+ top.with_pending_state(|state| {
+ state.size = Some(size.into());
+ });
+ top.send_configure();
+ }
+ } else if let Some(x11) = w.x11_surface() {
+ // Unlike xdg-shell, an X11 client's real on-screen position
+ // is part of its own window state - it has to be told on
+ // every move, not just every resize, the same way a real
+ // X11 window manager sends continuous `ConfigureNotify`
+ // during an interactive drag. Without this branch at all,
+ // `sync_geometry` never reconfigured an XWayland window a
+ // second time past its initial map: `space.map_element`
+ // above still moved smithay's own tracked position (see
+ // `resync_stacking_order`'s doc comment for the real
+ // z-order side effect that has, since fixed below) and the
+ // border/titlebar still redrew at the new `Window.geometry`
+ // (both read it fresh every frame), but the real X11
+ // client window was never told to move or resize - any
+ // drag, resize, maximize, edge-snap, or tiling re-layout of
+ // an XWayland-backed app left its actual content frozen at
+ // its original position/size forever while srdwm's own
+ // decoration moved freely around it.
+ let _ = x11.configure(Rectangle::new((geom.x, geom.y + band).into(), size.into()));
}
}
- if self.decorations.contains_key(&id) {
+ if size_changed && self.decorations.contains_key(&id) {
self.redraw_decoration_buffer(id);
}
+ // See `resync_stacking_order`'s doc comment: `map_element` above
+ // always re-stacks its target to the top of `Space`'s own order as
+ // a side effect of updating position, `activate` or not - and
+ // `sync_geometry` runs for reasons with nothing to do with raising
+ // a window (a title changing, an ordinary resize frame), so left
+ // uncorrected this silently, non-deterministically desynced
+ // `Space`'s notion of "on top" from `WindowManager`'s.
+ if moved {
+ self.resync_stacking_order();
+ }
+ }
+
+ /// Re-broadcasts any dock/panel-facing protocol state that changed by a
+ /// path with no direct hook back into this module - specifically a
+ /// compositor keybinding via `crates/config`'s `WindowAction`/
+ /// `srd.workspace.*` API, which only ever touches `WindowManager` and
+ /// has no way to reach `CompState`. See `foreign_toplevel::
+ /// broadcast_dirty_state` and `workspace::broadcast_dirty_active`'s own
+ /// doc comments for the full story; called once per frame alongside
+ /// `tick_animations`, from both backends' poll loops.
+ pub(crate) fn tick_dirty_broadcasts(&mut self) {
+ foreign_toplevel::broadcast_dirty_state(self);
+ workspace::broadcast_dirty_active(self);
+ output_management::broadcast_dirty_outputs(self);
+ }
+
+ /// Advances every in-flight `WindowAnim` by one frame; called once per
+ /// redraw from both backends' poll loops. A finished tween is dropped
+ /// *before* its final `sync_geometry` call, so that call lands exactly
+ /// on `Window.geometry` (the authoritative target) rather than on
+ /// whatever the eased curve's last sub-pixel step happened to be.
+ pub(crate) fn tick_animations(&mut self) {
+ if self.window_anims.is_empty() {
+ return;
+ }
+ let ids: Vec<WindowId> = self.window_anims.keys().copied().collect();
+ for id in ids {
+ if self.window_anims.get(&id).is_some_and(WindowAnim::is_done) {
+ self.window_anims.remove(&id);
+ }
+ self.sync_geometry(id);
+ }
+ }
+
+ /// Re-applies `WindowManager`'s own stacking order to `Space`, bottom
+ /// to top.
+ ///
+ /// `Space::map_element` (smithay 0.7.0) always re-stacks its target to
+ /// the top of `Space`'s internal order as an unconditional side effect
+ /// of updating its tracked position - true regardless of the
+ /// `activate` argument, and there is no "move without restacking" in
+ /// this smithay version. `sync_geometry` calls `map_element` for
+ /// reasons that have nothing to do with raising a window at all (a
+ /// title/app_id changing, an ordinary resize frame, a workspace
+ /// switch), so every one of those silently raised the window it
+ /// touched to the top of `Space`'s order regardless of which window
+ /// `WindowManager`/the user actually considered focused or on top.
+ /// Real-world effect, confirmed live: two windows created moments
+ /// apart, each independently going through their own startup
+ /// title/app_id negotiation, would each trigger a handful of
+ /// `sync_geometry` calls purely from that startup sequence - so
+ /// whichever one happened to settle *last* silently won `Space`'s
+ /// notion of "on top", a race with no relationship to which window was
+ /// actually focused. Reported live as a background window's content
+ /// and decoration randomly painting in front of the actually-focused
+ /// window on top of it. Calling this right after any `map_element`
+ /// restores `Space`'s order to exactly match `WindowManager.order`
+ /// (which `restack_pinned` already keeps pinned windows at the tail
+ /// of), so the two can never drift apart again.
+ fn resync_stacking_order(&mut self) {
+ let order: Vec<WindowId> = self.wm.borrow().stacking_order().map(|w| w.id).collect();
+ for id in order {
+ if let Some(w) = self.id_to_window.get(&id).cloned() {
+ self.space.raise_element(&w, false);
+ }
+ }
}
}
-pub(crate) fn with_toplevel_title(toplevel: &ToplevelSurface) -> Option<String> {
- smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| {
+pub(crate) fn with_toplevel_title(surface: &WlSurface) -> Option<String> {
+ smithay::wayland::compositor::with_states(surface, |states| {
states.data_map.get::<XdgToplevelSurfaceData>().map(|d| d.lock().unwrap().title.clone().unwrap_or_default())
})
}
+
+/// Same pattern as `with_toplevel_title`, for `app_id` - the xdg-shell
+/// equivalent of `WM_CLASS`, and what `srd.rule({ class = ... })` matches
+/// against (`crates/core/src/rules.rs`).
+///
+/// Nothing read this before: `new_managed_window` populated `Window.title`
+/// from `with_toplevel_title` but never touched `Window.app_id` at all, so
+/// every native Wayland window had an *empty* app_id the entire time rules
+/// are evaluated (`WindowManager::add_window` matches rules once, at
+/// creation). Every `class`-based rule - including the Firefox
+/// `decorated = false` one meant to stop srdwm drawing a second titlebar
+/// on top of Firefox's own - could therefore never match a native Wayland
+/// client, only an XWayland one (`map_window_request` in xwayland.rs does
+/// set `app_id` correctly, from `X11Surface::class()`). Reported live as
+/// Firefox showing two titlebars, "same for other applications" - exactly
+/// what this predicts, since it silently breaks every class-matched rule
+/// for every native Wayland app, not just Firefox's.
+pub(crate) fn with_toplevel_app_id(surface: &WlSurface) -> Option<String> {
+ smithay::wayland::compositor::with_states(surface, |states| {
+ states.data_map.get::<XdgToplevelSurfaceData>().map(|d| d.lock().unwrap().app_id.clone().unwrap_or_default())
+ })
+}
+
+/// Re-reads title/app_id from the surface's own xdg-shell state and updates
+/// `Window`/foreign-toplevel listeners if either changed since last read.
+///
+/// Needed because a fresh toplevel's `new_toplevel` fires at
+/// `xdg_surface.get_toplevel()` - role assignment - which for essentially
+/// every real client happens *before* the `set_title`/`set_app_id`/first
+/// `commit()` sequence that actually supplies them. `new_managed_window`
+/// reading those fields at that moment (see its own comment) reliably got
+/// nothing: not a race, a fixed ordering every client hits, confirmed live
+/// by a peer session capturing the raw `zwlr_foreign_toplevel_handle_v1`
+/// wire output and finding `app_id`/`title` empty on every window. Unlike
+/// `Window.geometry`/state (double-buffered per xdg-shell semantics),
+/// title and app_id are plain immediate-apply requests in smithay's own
+/// `XdgToplevelSurfaceData`, so re-reading them on every commit - cheap,
+/// and this is already a per-commit hook - keeps `Window.title`/`app_id`
+/// (and anything, like `srd.rule`, that reads them) correct from the first
+/// real commit onward instead of frozen at an empty initial snapshot.
+pub(crate) fn sync_toplevel_metadata(state: &mut CompState, id: WindowId, surface: &WlSurface) {
+ let title = with_toplevel_title(surface).unwrap_or_default();
+ let app_id = with_toplevel_app_id(surface).unwrap_or_default();
+ let changed = {
+ let mut wm = state.wm.borrow_mut();
+ let Some(w) = wm.window_mut(id) else { return };
+ let changed = w.title != title || w.app_id != app_id;
+ w.title = title;
+ w.app_id = app_id;
+ changed
+ };
+ if changed {
+ // Now that `title`/`app_id` are real, give `add_window`'s rule
+ // match (which ran before either was set - see `Window::
+ // rules_applied`'s doc comment) a real chance. Only the *first*
+ // successful evaluation (rule actually matched, `Some` returned)
+ // warrants `redraw_decoration_buffer`/`sync_geometry` - unlike
+ // `set_decorated_from_mode`, this fires on every subsequent title
+ // change too (a page finishing loading, long after the window's
+ // own creation), and `sync_geometry` re-stacks the window to the
+ // top of smithay's `Space` as a side effect of `map_element`
+ // (true regardless of its `activate` argument - there's no
+ // "move without restacking" in this smithay version). Calling it
+ // unconditionally here silently yanked an unrelated, unfocused
+ // window back to the front any time its title happened to
+ // update - reported live as an older window jumping in front of
+ // a newer, focused one with no user action to explain it.
+ if state.wm.borrow_mut().reapply_rules_if_pending(id) {
+ state.redraw_decoration_buffer(id);
+ state.sync_geometry(id);
+ }
+ crate::foreign_toplevel::send_state(state, id);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn focused_window_keeps_its_configured_colour() {
+ assert_eq!(effective_border_color((136, 192, 208), true), (136, 192, 208));
+ }
+
+ #[test]
+ fn unfocused_window_is_dimmed_but_still_recognisably_that_colour() {
+ let dimmed = effective_border_color((136, 192, 208), false);
+ // Dimmer in every channel...
+ assert!(dimmed.0 < 136 && dimmed.1 < 192 && dimmed.2 < 208);
+ // ...but not black, and the channels' relative order is preserved
+ // (still "bluish", not just "gray") so a per-window colour set via
+ // a rule stays distinguishable from another window's even while
+ // unfocused.
+ assert!(dimmed.0 > 0 || dimmed.1 > 0 || dimmed.2 > 0);
+ assert!(dimmed.2 >= dimmed.1 && dimmed.1 >= dimmed.0);
+ }
+
+ #[test]
+ fn window_anim_starts_at_from_and_ends_at_to() {
+ let anim = WindowAnim {
+ from: srdwm_core::Rect::new(0, 100, 300, 200),
+ to: srdwm_core::Rect::new(0, 0, 300, 200),
+ start: Instant::now(),
+ duration: Duration::from_millis(200),
+ };
+ assert_eq!(anim.current_rect(), anim.from);
+ assert!(!anim.is_done());
+ }
+
+ #[test]
+ fn window_anim_is_done_and_settles_exactly_on_to_once_duration_elapses() {
+ let anim = WindowAnim {
+ from: srdwm_core::Rect::new(0, 100, 300, 200),
+ to: srdwm_core::Rect::new(50, 0, 600, 400),
+ start: Instant::now() - Duration::from_millis(500),
+ duration: Duration::from_millis(200),
+ };
+ assert!(anim.is_done());
+ assert_eq!(anim.current_rect(), anim.to);
+ }
+
+ #[test]
+ fn window_anim_midway_is_strictly_between_from_and_to_on_every_axis() {
+ let anim = WindowAnim {
+ from: srdwm_core::Rect::new(0, 200, 200, 100),
+ to: srdwm_core::Rect::new(100, 0, 800, 600),
+ start: Instant::now() - Duration::from_millis(100),
+ duration: Duration::from_millis(200),
+ };
+ let r = anim.current_rect();
+ assert!(r.x > 0 && r.x < 100);
+ assert!(r.y > 0 && r.y < 200);
+ assert!(r.width > 200 && r.width < 800);
+ assert!(r.height > 100 && r.height < 600);
+ }
+}
diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs
index 95ef571..96d57d5 100644
--- a/crates/wayland/src/udev.rs
+++ b/crates/wayland/src/udev.rs
@@ -38,20 +38,21 @@ use smithay::backend::input::{
};
use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface};
use smithay::backend::renderer::damage::OutputDamageTracker;
-use smithay::backend::renderer::element::memory::{MemoryRenderBuffer, MemoryRenderBufferRenderElement};
+use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::pixman::PixmanRenderer;
-use smithay::backend::renderer::Bind;
+use smithay::backend::renderer::{Bind, ImportDma};
use smithay::backend::session::{libseat::LibSeatSession, libseat::LibSeatSessionNotifier, Event as SessionEvent, Session};
use smithay::backend::udev::{self, UdevBackend, UdevEvent};
use smithay::desktop::space::render_output;
-use smithay::desktop::{layer_map_for_output, Space};
+use smithay::desktop::{layer_map_for_output, PopupManager, Space};
+use smithay::backend::input::AxisSource;
use smithay::input::pointer::AxisFrame;
use smithay::input::SeatState;
use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel};
use smithay::reexports::calloop::generic::{FdWrapper, Generic};
use smithay::reexports::calloop::{EventLoop, Interest, LoopHandle, Mode as CalloopMode, PostAction};
-use smithay::reexports::drm::buffer::DrmFourcc;
+use smithay::reexports::drm::buffer::{Buffer as DrmBufferTrait, DrmFourcc};
use smithay::reexports::drm::control::{
connector, crtc, dumbbuffer::DumbBuffer, framebuffer, Device as ControlDevice, Event as DrmEvent, Mode as DrmMode,
ModeTypeFlags, PageFlipFlags,
@@ -62,18 +63,21 @@ use smithay::reexports::pixman::{FormatCode, Image};
use smithay::reexports::rustix;
use smithay::reexports::wayland_server::backend::GlobalId;
use smithay::reexports::wayland_server::{Client, Display, DisplayHandle, ListeningSocket};
-use smithay::utils::{Logical, Point, Transform};
+use smithay::utils::{Logical, Physical, Point, Rectangle, Scale, Size, Transform};
use smithay::wayland::compositor::CompositorState;
+use smithay::wayland::dmabuf::DmabufState;
use smithay::wayland::selection::data_device::DataDeviceState;
use smithay::wayland::selection::primary_selection::PrimarySelectionState;
use smithay::wayland::selection::wlr_data_control::DataControlState;
use smithay::wayland::shell::xdg::decoration::XdgDecorationState;
use smithay::wayland::shell::xdg::XdgShellState;
use smithay::wayland::shm::ShmState;
+use smithay::wayland::xdg_activation::XdgActivationState;
use srdwm_core::{Event as CoreEvent, WindowManager};
use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult};
+use crate::decoration;
use crate::err;
use crate::input::{handle_keyboard_key_event, handle_pointer_button, handle_pointer_position};
use crate::state::{ClientState, CompState};
@@ -117,6 +121,24 @@ pub(crate) struct UdevHead {
/// A flip is in flight; the next frame for this head waits for the DRM
/// page-flip event (matched by `crtc`) before starting.
pub(crate) flip_pending: bool,
+ /// Per-buffer-slot age passed to `damage_tracker.render_output`: how
+ /// many *damage-producing* renders ago that exact buffer was last
+ /// brought fully up to date. 0 means "never rendered, contents
+ /// undefined" and forces a full redraw. This used to be hardcoded to 0
+ /// on every single call regardless - which, per
+ /// `OutputDamageTracker::damage_output_internal`, forces the entire
+ /// output geometry to be treated as damaged every time, so every frame
+ /// was a full-screen software (pixman) recomposite plus a page-flip,
+ /// nonstop, at whatever rate the event loop's 16ms dispatch timeout
+ /// allowed - continuously, even with a fully idle desktop. That
+ /// competes for the same single thread's CPU time as libinput event
+ /// processing and is exactly what `client bug: event processing
+ /// lagging behind` (logged for both the keyboard and the mouse) was
+ /// reporting. With correct ages, a call that finds no real damage
+ /// returns near-free (`damage_output_internal`'s own element/geometry
+ /// comparison, no pixel work) and skips the flip entirely instead of
+ /// always finding "damage".
+ pub(crate) ages: [usize; 2],
/// Origin of this head in the global coordinate space.
pub(crate) location: Point<i32, Logical>,
pub(crate) size: (i32, i32),
@@ -154,6 +176,8 @@ impl CompState {
/// monitors on different refresh rates each run at their own pace
/// instead of the slowest one gating the rest.
pub(crate) fn render_udev_frame(&mut self) {
+ self.tick_animations();
+ self.tick_dirty_broadcasts();
let locked = self.lock.locked;
let elapsed = self.start_time.elapsed();
// Drained before the `&mut self.udev` borrow below, so screencopy can
@@ -161,12 +185,41 @@ impl CompState {
let mut captures = std::mem::take(&mut self.screencopy_pending);
// Same reason: the cursor needs the renderer that borrow owns.
let cursor_status = self.cursor_status.clone();
- let cursor_buffer = self.cursor_buffer.clone();
+ let cursor_buffers = self.cursor_buffers.clone();
+
+ // Border geometry is in global space, independent of which head
+ // renders it, so it's gathered once here rather than per head.
+ // Buffers are pre-built for the same reason as `cursor_buffers`:
+ // Rendered per window, front-to-back (topmost first), each window's
+ // content immediately followed by its decoration and border --
+ // fixes the same cross-window ordering bug documented in
+ // `winit.rs`'s render loop: a background window's titlebar could
+ // otherwise show through in front of the actually-focused window on
+ // top of it, since decorations/borders used to be a single flat
+ // layer drawn unconditionally above *every* window's content
+ // regardless of real stacking order. `visible_windows_front_to_back`
+ // is `WindowManager.order` reversed - not `visible_windows`, which
+ // iterates the `windows` HashMap with no ordering guarantee - the
+ // same "topmost first" convention `hit_test`/`window_at` use.
+ // Fetched once here (`&mut self` fields, id_to_window/space lookups
+ // happen per head below without needing `self` itself mutably) --
+ // see the per-head loop for why decoration/border buffers still get
+ // looked up fresh per head (head-local `origin` translation).
+ let ids: Vec<srdwm_core::WindowId> = if locked { Vec::new() } else { self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect() };
+ let focused = self.wm.borrow().focused_id();
+ let popup_targets = if locked { Vec::new() } else { crate::elements::popup_targets(self) };
// Which heads are eligible, and what each needs, gathered before the
- // mutable borrow of `self.udev`.
- let Some(udev) = self.udev.as_ref() else { return };
+ // mutable borrow of `self.udev`. Both early-outs below give the
+ // `captures` taken above nowhere to go this pass - put them back
+ // rather than silently dropping a client's pending screenshot
+ // because a VT switch happened to be in progress at that instant.
+ let Some(udev) = self.udev.as_ref() else {
+ self.screencopy_pending.extend(captures);
+ return;
+ };
if !udev.active {
+ self.screencopy_pending.extend(captures);
return;
}
let ready: Vec<(usize, Output)> = udev
@@ -176,22 +229,23 @@ impl CompState {
.filter(|(_, h)| !h.flip_pending)
.map(|(i, h)| (i, h.output.clone()))
.collect();
-
- let mut presented: Vec<Output> = Vec::new();
+ // Kept separately from `presented` below: layer-shell surfaces
+ // (bars, docks) get their frame callback every pass regardless of
+ // `has_damage`, unlike toplevel windows - see the callback loop at
+ // the end of this function for why the two can't share one gate.
+ let ready_outputs: Vec<Output> = ready.iter().map(|(_, o)| o.clone()).collect();
+
+ // Damage rects travel alongside each presented output so the
+ // frame-callback loop below (after `udev` is no longer borrowed)
+ // can notify only the windows that damage actually overlapped --
+ // see `windows_touched_by_damage`'s doc comment in elements.rs.
+ let mut presented: Vec<(Output, Vec<Rectangle<i32, Physical>>)> = Vec::new();
for (index, output) in ready {
let lock_surface = self.lock_surface_for(&output).cloned();
- // Decoration elements are built per head: `from_buffer` needs the
+ // Content/decoration elements are built per head: both need the
// renderer, and geometry is translated into head-local space.
let origin = self.udev.as_ref().map(|u| u.heads[index].location).unwrap_or_default();
- let decorations: Vec<(srdwm_core::Rect, MemoryRenderBuffer)> = if locked {
- Vec::new()
- } else {
- self.decorations
- .iter()
- .filter_map(|(&id, deco)| self.wm.borrow().window(id).map(|w| (w.geometry, deco.clone())))
- .collect()
- };
let Some(udev) = self.udev.as_mut() else { return };
let head = &mut udev.heads[index];
@@ -206,19 +260,143 @@ impl CompState {
let hsize = udev.heads[index].size;
custom_elements.extend(crate::cursor::render_elements(
&cursor_status,
- &cursor_buffer,
+ &cursor_buffers,
&mut udev.renderer,
pointer_pos,
origin,
hsize,
));
- for (geom, deco) in &decorations {
- let pos = ((geom.x - origin.x) as f64, (geom.y - origin.y) as f64);
- match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, deco, None, None, None, Kind::Unspecified) {
+ // The right-click titlebar menu, if open - pushed right
+ // after the cursor so it's still topmost over every window
+ // but never hides the pointer itself (you need to see what
+ // you're about to click).
+ if let (Some(menu), Some(buffer)) = (self.context_menu.as_ref(), self.context_menu_buffer.as_ref()) {
+ let pos = ((menu.pos.0 - origin.x) as f64, (menu.pos.1 - origin.y) as f64);
+ match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) {
Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
- Err(e) => log::warn!("udev: failed to import titlebar buffer: {e}"),
+ Err(e) => log::warn!("udev: failed to import context menu buffer: {e}"),
}
}
+ // Content still comes from `render_output`'s own `spaces`
+ // argument (`self.space`, passed below), not from a
+ // per-window `render_elements` call here - see the long
+ // comment on the matching loop in `winit.rs`, and `state.rs`'s
+ // `resync_stacking_order` doc comment, for the real
+ // stacking-order bug this was chasing and its actual fix.
+ // Windows stacked in front of whichever one border/
+ // decoration is being built right now - `ids` is already
+ // front-to-back, so this only ever needs appending to, not
+ // recomputing. Only a window's own *content* occludes
+ // correctly on its own path (via `space`, real stacking
+ // order respected); everything drawn here goes through
+ // `custom_elements`, which composites above *all* content
+ // unconditionally, so both the border strips and the
+ // titlebar bitmap below need this explicit occlusion test
+ // against it.
+ let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len());
+ for &id in &ids {
+ let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
+ // `w.geometry` is the animation's *target*, not
+ // necessarily where the window is actually drawn this
+ // frame - during a maximize/fullscreen/open-slide tween,
+ // `sync_geometry` already renders the window's own
+ // content at `window_anims`' interpolated rect (see its
+ // doc comment), but this loop used to read `w.geometry`
+ // straight from the model regardless, so the border and
+ // titlebar sat at the final rect while the content they
+ // were supposed to outline slid past underneath them --
+ // reported live as the border "not flush" with the
+ // window during any animated transition. Every use of
+ // this window's geometry below (titlebar/border
+ // placement *and* the occlusion test against later
+ // windows) has to agree with what `sync_geometry` mapped
+ // the content to, or they drift apart again.
+ let geom = self.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry);
+ if let Some(deco) = self.decorations.get(&id) {
+ // Fragment-clipped, same as the three solid border
+ // strips below - an *all-or-nothing* version of
+ // this (skip only once fully covered) was tried
+ // first and reported live as still showing "the
+ // behind window's bar": a titlebar only *partially*
+ // covered - the common case for cascaded/
+ // overlapping windows - drew in full regardless,
+ // bleeding through the covered part. `from_buffer`'s
+ // `src` parameter crops the bitmap itself, so each
+ // visible fragment can come from the matching
+ // sub-rect of the source image rather than the
+ // whole thing.
+ let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT);
+ for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) {
+ let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64);
+ let src = Rectangle::new(
+ Point::from(((fragment.x - titlebar_rect.x) as f64, (fragment.y - titlebar_rect.y) as f64)),
+ Size::from((fragment.width as f64, fragment.height as f64)),
+ );
+ match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, deco, None, Some(src), None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("udev: failed to import titlebar buffer: {e}"),
+ }
+ }
+ }
+ // Border strips sit entirely outside this window's own
+ // `geometry` (see `decoration::border_strips`), so they
+ // never overlap its own decoration/content - draw
+ // order against those doesn't matter here, only against
+ // other windows', which iterating `ids` in stacking
+ // order already gets right *for windows also drawn via
+ // this same custom_elements loop* - but not against
+ // any window's own *content*, which is why `occluders`
+ // below is still needed even with that ordering.
+ if w.border_width > 0 {
+ let color = crate::state::effective_border_color(w.border_color, focused == Some(id));
+ let strips = decoration::border_strips(geom, w.border_width);
+ // Strip 0 (top) rounded to match the titlebar under
+ // it - see `render_border_top`'s doc comment - so
+ // it's a cached bitmap (rebuilt only in
+ // `redraw_decoration_buffer`, same as the titlebar
+ // itself), not rasterized fresh here every frame.
+ // Not fragment-clipped like the other three strips
+ // below - cropping a bitmap's source rect per
+ // fragment is real extra work for a strip that's
+ // only `border_width` pixels tall to begin with, so
+ // this only handles the all-or-nothing case: skip
+ // entirely once *fully* covered, accept a small
+ // residual bleed while only partially covered.
+ if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() {
+ if let Some(buffer) = self.border_top_decorations.get(&id) {
+ let pos = ((strips[0].x - origin.x) as f64, (strips[0].y - origin.y) as f64);
+ match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("udev: failed to import top border buffer: {e}"),
+ }
+ }
+ }
+ // The other three strips are persistent
+ // `SolidColorBuffer`s updated in place, not rebuilt
+ // with a fresh `Id` every frame - see
+ // `elements::border_side_render_element`'s doc
+ // comment for why that distinction is load-bearing
+ // for damage tracking, not cosmetic. Each strip is
+ // further split into whatever fragments remain
+ // visible after subtracting `occluders`, since a
+ // whole unclipped strip is exactly the bug fixed
+ // here.
+ let pool = self.border_side_buffers.entry(id).or_default();
+ let mut buf_index = 0;
+ for strip in &strips[1..] {
+ if strip.width == 0 || strip.height == 0 {
+ continue;
+ }
+ for fragment in crate::elements::visible_border_fragments(*strip, &occluders) {
+ let buf = crate::elements::border_fragment_buffer(pool, buf_index);
+ buf_index += 1;
+ custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(buf, fragment, color, (origin.x, origin.y))));
+ }
+ }
+ }
+ occluders.push(geom);
+ }
+ custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, &mut udev.renderer, (origin.x, origin.y)));
}
let lock_elements = if locked {
crate::lock::lock_render_elements(lock_surface.as_ref(), &mut udev.renderer)
@@ -240,7 +418,7 @@ impl CompState {
let result = if locked {
head.damage_tracker
.render_output(&mut udev.renderer, &mut framebuffer, 0, &lock_elements, [0.0, 0.0, 0.0, 1.0])
- .map(|r| r.damage.is_some())
+ .map(|r| (r.damage.is_some(), Vec::new()))
.map_err(|e| e.to_string())
} else {
render_output(
@@ -248,24 +426,34 @@ impl CompState {
&mut udev.renderer,
&mut framebuffer,
1.0,
- 0, // always a full redraw: buffer "age" tracking isn't worth the complexity for a software-only backend
+ head.ages[back],
[&self.space],
&custom_elements,
&mut head.damage_tracker,
[0.05, 0.05, 0.08, 1.0],
)
- .map(|r| r.damage.is_some())
- // Both arms reduce to "was there damage"; the two error types
- // differ, so they are flattened to a message here.
+ .map(|r| (r.damage.is_some(), r.damage.cloned().unwrap_or_default()))
+ // Both arms reduce to "was there damage" plus the damage
+ // rects themselves; the two error types differ, so they are
+ // flattened to a message here.
.map_err(|e| e.to_string())
};
if !locked {
- crate::screencopy::service_pending(std::mem::take(&mut captures), &mut udev.renderer, &framebuffer);
+ // Only this head's own captures: `captures` holds requests
+ // for every output, and each must be read back from the
+ // framebuffer it was actually requested against, not
+ // whichever head happens to render first in this loop (a
+ // multi-monitor capture would otherwise silently read the
+ // wrong screen). Whatever doesn't match `output` stays in
+ // `captures` for a later head this same pass.
+ let (mine, rest): (Vec<_>, Vec<_>) = captures.into_iter().partition(|c| c.output == output);
+ captures = rest;
+ crate::screencopy::service_pending(mine, &mut udev.renderer, &framebuffer);
}
drop(framebuffer);
- let has_damage = match result {
- Ok(d) => d,
+ let (has_damage, damage_rects) = match result {
+ Ok(v) => v,
Err(e) => {
log::error!("udev: render_output failed: {e}");
continue;
@@ -277,24 +465,197 @@ impl CompState {
log::error!("udev: page flip failed: {e}");
continue;
}
+ // This buffer is now fully up to date. It won't be rendered
+ // into again until the *other* slot has also been presented
+ // once (strict two-buffer alternation), so by then it will
+ // be exactly 2 damage-producing renders stale - matching
+ // `damage_tracker`'s own history, which only advances on
+ // calls that actually found damage (see `ages`' doc
+ // comment).
+ head.ages[back] = 2;
+ // Only a head that actually presented a new frame should
+ // tell its windows they may render their next one - this
+ // used to run unconditionally for every "ready" head (i.e.
+ // every head not already mid-flip) on every single call to
+ // this function, which is every ~16ms regardless of
+ // activity. A client that renders on the standard
+ // wait-for-frame-callback pattern (which is most of
+ // them - confirmed live: wezterm-gui pinned at 140%+ CPU
+ // sitting on a fully idle, unchanged terminal) had no
+ // reason not to redraw at whatever rate this loop cycled,
+ // forever, since it kept getting told a new frame was
+ // wanted whether or not the screen had changed at all.
+ presented.push((output, damage_rects));
}
- presented.push(output);
}
// Frame callbacks + lock confirmation, once the `udev` borrow is done.
- for output in presented {
+ for (output, damage_rects) in presented {
if locked {
let surface = self.lock_surface_for(&output).cloned();
crate::lock::send_lock_frame(surface.as_ref(), &output, elapsed);
self.confirm_lock_if_presented(&output);
} else {
let out = output.clone();
- self.space.elements().for_each(|w| w.send_frame(&out, elapsed, None, |_, _| Some(out.clone())));
+ let scale = Scale::from(out.current_scale().fractional_scale());
+ for w in crate::elements::windows_touched_by_damage(&self.space, &damage_rects, scale) {
+ w.send_frame(&out, elapsed, None, |_, _| Some(out.clone()));
+ }
+ }
+ }
+ // Deliberately unconditional - not folded into the `presented`
+ // loop above, and not gated on any head having had damage this
+ // tick at all. The whole point of `always_notify` is covering the
+ // case where the output has *no* damage whatsoever (a fully idle
+ // desktop, cursor not moving) but the focused/hovered window still
+ // has a pending callback it needs answered to unblock an input-
+ // driven redraw - GTK's frame-clock model (Firefox's Wayland
+ // vsync source included) paces every repaint through that
+ // callback, even the first one after being idle, with no "just
+ // commit immediately" fallback. Nesting this inside the `presented`
+ // loop (the first version of this fix) meant it only ever ran on a
+ // tick that already had damage from something else happening --
+ // i.e. never in the exact scenario it exists for. Reported live as
+ // clicks in Firefox still doing nothing at all, not just
+ // intermittently, after the first version of this fix.
+ if !locked {
+ let pointer_pos = self.udev.as_ref().map(|u| u.pointer_pos).unwrap_or_default();
+ let wm = self.wm.borrow();
+ let always_notify = [wm.focused_id(), wm.window_at(pointer_pos.x as i32, pointer_pos.y as i32)];
+ drop(wm);
+ let outputs: Vec<Output> = self.outputs.iter().map(|e| e.output.clone()).collect();
+ for w in always_notify.into_iter().flatten().filter_map(|id| self.id_to_window.get(&id)) {
+ for out in &outputs {
+ w.send_frame(out, elapsed, None, |_, _| Some(out.clone()));
+ }
+ }
+ }
+ // Layer-shell surfaces (bars, docks, launchers) get their frame
+ // callback on every pass, unconditionally - NOT folded into the
+ // `presented`/`has_damage` gate above.
+ //
+ // That gate exists because most toplevel clients redraw on the
+ // standard wait-for-callback loop regardless of whether their own
+ // content changed (confirmed live: wezterm-gui pinned at 140%+ CPU
+ // on a fully idle terminal when it got a callback every ~16ms
+ // whether or not the screen had changed). Gating toplevel callbacks
+ // on real output damage fixed that.
+ //
+ // Applying the same gate to layer surfaces creates a real deadlock
+ // instead: many (GTK4/AGS among them) drive their *entire* repaint
+ // loop off frame callbacks with no independent timer fallback --
+ // paint once, request a callback, wait. If nothing ELSE on the
+ // desktop ever produces damage again (a static terminal, no other
+ // animation), that callback never arrives, so the surface can never
+ // draw its next frame, which means it can never produce damage,
+ // which means it never gets a callback - permanently frozen after
+ // exactly one frame. Confirmed live: AGS and waybar both hung this
+ // way, one frame in, with `wl_surface.frame` requests that were
+ // never answered (see docs/PANEL_SUPPORT_TODO.md).
+ //
+ // Splitting the gate is safe rather than reintroducing the wezterm
+ // bug: there are at most a handful of layer surfaces on a real
+ // desktop (a bar, maybe a dock/launcher), their content is cheap to
+ // redraw even when done needlessly, and periodic UI chrome (a
+ // clock, a resource graph) is exactly the case frame callbacks
+ // exist to pace - unlike a full toplevel window, whose redraw cost
+ // is what made the unconditional case expensive in the first place.
+ if !locked {
+ for output in &ready_outputs {
+ for layer in layer_map_for_output(output).layers() {
+ layer.send_frame(output, elapsed, None, |_, _| Some(output.clone()));
+ }
}
}
if locked {
crate::screencopy::fail_pending(captures);
+ } else if !captures.is_empty() {
+ // Left over because their target head wasn't in `ready` this
+ // pass (e.g. mid-page-flip). Put back rather than dropped: this
+ // function runs again on the next poll tick (or the page-flip
+ // completion that made the head ready), so the capture gets
+ // another chance instead of silently vanishing - which is what
+ // made `grim` hang waiting on a `ready`/`failed` that would
+ // otherwise never come (see docs/PANEL_SUPPORT_TODO.md, P1).
+ self.screencopy_pending.extend(captures);
+ }
+ }
+
+ /// Sets a connector's DPMS mode via the generic KMS "DPMS" property --
+ /// there is no dedicated legacy-API call for this in `drm-rs`, only the
+ /// same `get_properties`/`set_property` pair every other connector
+ /// property goes through, so the property has to be found by name each
+ /// time rather than through some `Dpms` -specific method. `None` if the
+ /// `wl_output` doesn't resolve to a live head, or the connector has no
+ /// "DPMS" property at all (rare on real hardware, but virtual/headless
+ /// outputs may not expose one) - either way maps to `zwlr_output_power_v1`'s
+ /// `failed` event, matching what the protocol asks for when the mode
+ /// can't be honoured.
+ pub(crate) fn set_output_power(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, on: bool) -> Option<()> {
+ // Raw KMS UAPI values for the "DPMS" connector property
+ // (`DRM_MODE_DPMS_ON`/`_OFF` in `drm_sys`/the kernel's
+ // `drm_mode.h`) - not worth a whole extra dependency on `drm-sys`
+ // just for two constants that have been stable since DPMS was
+ // added to the DRM UAPI.
+ const DRM_MODE_DPMS_ON: u64 = 0;
+ const DRM_MODE_DPMS_OFF: u64 = 3;
+
+ let target = self.output_for_wl(wl_output)?.output.clone();
+ let udev = self.udev.as_ref()?;
+ let head = udev.heads.iter().find(|h| h.output == target)?;
+ let props = udev.card.get_properties(head.connector).ok()?;
+ let dpms_prop = props.as_props_and_values().0.iter().copied().find(|&handle| udev.card.get_property(handle).is_ok_and(|info| info.name().to_str() == Ok("DPMS")))?;
+ let mode = if on { DRM_MODE_DPMS_ON } else { DRM_MODE_DPMS_OFF };
+ udev.card.set_property(head.connector, dpms_prop, mode).ok()
+ }
+
+ /// The CRTC's gamma ramp length, in elements per channel - what
+ /// `zwlr_gamma_control_v1.gamma_size` reports so a client knows how
+ /// large a table `set_gamma` expects. `None` if the output doesn't
+ /// resolve to a live head, or the CRTC reports a zero-length ramp
+ /// (no gamma hardware, common on virtual/headless outputs).
+ pub(crate) fn gamma_ramp_size(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput) -> Option<u32> {
+ let target = self.output_for_wl(wl_output)?.output.clone();
+ let udev = self.udev.as_ref()?;
+ let head = udev.heads.iter().find(|h| h.output == target)?;
+ let len = udev.card.get_crtc(head.crtc).ok()?.gamma_length();
+ (len > 0).then_some(len)
+ }
+
+ /// Reads a client-supplied gamma table (`zwlr_gamma_control_v1.
+ /// set_gamma`'s `fd`: a memory-mapped blob of `gamma_size` `u16`s per
+ /// channel, red then green then blue, per the protocol) and applies it
+ /// to the CRTC. `None` on any failure - output/head not found, the
+ /// blob is the wrong size, or the DRM `set_gamma` call itself fails --
+ /// which the caller maps to `zwlr_gamma_control_v1.failed`, exactly
+ /// what the protocol specifies for "setting the gamma tables failed".
+ pub(crate) fn set_gamma_ramp(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, fd: std::os::fd::OwnedFd) -> Option<()> {
+ let target = self.output_for_wl(wl_output)?.output.clone();
+ let udev = self.udev.as_ref()?;
+ let head = udev.heads.iter().find(|h| h.output == target)?;
+ let size = udev.card.get_crtc(head.crtc).ok()?.gamma_length() as usize;
+ if size == 0 {
+ return None;
}
+ // Three channels, two bytes (one native-endian u16) per element --
+ // client and compositor are always the same machine, so there is
+ // no cross-endianness concern to handle here, unlike an over-the-
+ // wire protocol value.
+ let expected_bytes = size * 3 * 2;
+ // SAFETY: the fd is a client-supplied shared-memory blob, mapped
+ // read-only for the duration of this call and never touched again
+ // afterwards - the same trust boundary `wl_shm` buffers already
+ // cross for every window's actual pixel content elsewhere in this
+ // codebase.
+ let map = unsafe { memmap2::MmapOptions::new().map(&fd) }.ok()?;
+ if map.len() < expected_bytes {
+ return None;
+ }
+ let read_channel = |offset: usize| -> Vec<u16> { map[offset..offset + size * 2].chunks_exact(2).map(|b| u16::from_ne_bytes([b[0], b[1]])).collect() };
+ let red = read_channel(0);
+ let green = read_channel(size * 2);
+ let blue = read_channel(size * 4);
+ udev.card.set_gamma(head.crtc, &red, &green, &blue).ok()
}
}
@@ -423,17 +784,35 @@ impl UdevHead {
/// scanout memory directly, to avoid tying that image's lifetime to an
/// mmap - see this module's docs) and flips to it.
fn copy_and_flip(&mut self, card: &Card, back: usize) -> std::io::Result<()> {
- let (stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height());
- let byte_len = stride * height;
+ let (src_stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height());
+ let byte_len = src_stride * height;
// SAFETY: `image` owns this memory and outlives the byte slice we
// construct from it here; we only read, and only for the duration
// of this call.
let src: &[u8] = unsafe { std::slice::from_raw_parts(self.buffers[back].image.data() as *const u8, byte_len) };
+ // The dumb buffer's pitch is whatever the kernel driver actually
+ // allocated, which the DRM API does not guarantee equals pixman's
+ // own `src_stride` (drivers are free to pad each row for
+ // alignment). This used to be a single flat `copy_from_slice` sized
+ // off `src_stride` alone; on any driver that pads, that copies each
+ // source row into the wrong offset in the destination, shearing the
+ // image diagonally by one row per `dst_stride - src_stride` bytes of
+ // padding. Copying row by row, each clamped to the narrower of the
+ // two strides, is correct regardless of whether the strides happen
+ // to match.
+ let dst_stride = self.buffers[back].dumb.pitch() as usize;
{
let mut mapping = card.map_dumb_buffer(&mut self.buffers[back].dumb)?;
let dst = mapping.as_mut();
- let len = byte_len.min(dst.len());
- dst[..len].copy_from_slice(&src[..len]);
+ let row_len = src_stride.min(dst_stride);
+ for row in 0..height {
+ let s = row * src_stride;
+ let d = row * dst_stride;
+ if s + row_len > src.len() || d + row_len > dst.len() {
+ break;
+ }
+ dst[d..d + row_len].copy_from_slice(&src[s..s + row_len]);
+ }
}
card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?;
self.flip_pending = true;
@@ -448,6 +827,7 @@ pub struct UdevPlatform {
listener: ListeningSocket,
clients: Vec<Client>,
pending: Rc<RefCell<Vec<CoreEvent>>>,
+ ipc: Option<srdwm_platform::IpcServer>,
}
impl UdevPlatform {
@@ -476,6 +856,16 @@ impl UdevPlatform {
let renderer = PixmanRenderer::new().map_err(err)?;
let dh = Display::<CompState>::new().map_err(err)?;
let display_handle = dh.handle();
+ // `zwp_linux_dmabuf_v1` - see `protocols.rs`'s `DmabufHandler` for
+ // why `PixmanRenderer`, a pure software renderer, can still import
+ // these (mmap, not GPU). `create_global` (v3) rather than the v4
+ // `..._with_default_feedback` variant: the latter needs a
+ // `main_device` `dev_t` to steer multi-GPU clients toward the
+ // right render node, which is a real gap worth closing later but
+ // not required for a single-GPU client to allocate and hand over a
+ // Linear-modifier buffer, which is all this backend can use anyway.
+ let mut dmabuf_state = DmabufState::new();
+ dmabuf_state.create_global::<CompState>(&display_handle, renderer.dmabuf_formats());
let mut heads: Vec<UdevHead> = Vec::new();
let mut output_entries: Vec<crate::state::OutputEntry> = Vec::new();
@@ -514,7 +904,17 @@ impl UdevPlatform {
DataControlState::new::<CompState, _>(&display_handle, Some(&primary_selection_state), |_| true);
let mut seat_state = SeatState::new();
let mut seat = seat_state.new_wl_seat(&display_handle, "seat0");
- seat.add_keyboard(Default::default(), 200, 25).map_err(err)?;
+ let system_xkb = crate::xkb_config::read();
+ let xkb_config = smithay::input::keyboard::XkbConfig {
+ rules: "",
+ model: system_xkb.model.as_deref().unwrap_or(""),
+ layout: system_xkb.layout.as_deref().unwrap_or(""),
+ variant: system_xkb.variant.as_deref().unwrap_or(""),
+ options: system_xkb.options.clone(),
+ };
+ // 600ms delay, not 200 - see `state.rs`'s `REPEAT_DELAY` doc
+ // comment for why.
+ seat.add_keyboard(xkb_config, 600, 25).map_err(err)?;
seat.add_pointer();
// Each output occupies its own slice of the global space, so a
@@ -538,9 +938,15 @@ impl UdevPlatform {
xdg_shell_state,
_xdg_decoration_state: xdg_decoration_state,
shm_state,
+ dmabuf_state,
+ xdg_activation_state: XdgActivationState::new::<CompState>(&display_handle),
+ _text_input_manager_state: smithay::wayland::text_input::TextInputManagerState::new::<CompState>(&display_handle),
+ _input_method_manager_state: smithay::wayland::input_method::InputMethodManagerState::new::<CompState, _>(&display_handle, |_client| true),
+ _gtk_shell_state: crate::gtk_shell::GtkShellState::new::<CompState>(&display_handle),
seat_state,
seat,
space,
+ popups: PopupManager::default(),
outputs: output_entries,
layer_shell_state: smithay::wayland::shell::wlr_layer::WlrLayerShellState::new::<CompState>(&display_handle),
dh: display_handle.clone(),
@@ -553,14 +959,45 @@ impl UdevPlatform {
),
_screencopy_state: crate::screencopy::ScreencopyState::new::<CompState>(&display_handle),
screencopy_pending: Vec::new(),
+ _foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&display_handle),
+ foreign_toplevel_managers: Vec::new(),
+ foreign_toplevel_handles: HashMap::new(),
+ _workspace_state: crate::workspace::WorkspaceManagerState::new::<CompState>(&display_handle),
+ _output_power_state: Some(crate::output_power::OutputPowerManagerState::new::<CompState>(&display_handle)),
+ _gamma_control_state: Some(crate::gamma_control::GammaControlManagerState::new::<CompState>(&display_handle)),
+ _output_management_state: crate::output_management::OutputManagementState::new::<CompState>(&display_handle),
+ output_managers: Vec::new(),
+ output_heads: HashMap::new(),
+ output_modes: HashMap::new(),
+ output_serial: 0,
+ last_broadcast_outputs: Vec::new(),
+ workspace_managers: Vec::new(),
+ workspace_groups: Vec::new(),
+ workspace_handles: HashMap::new(),
+ _viewporter_state: smithay::wayland::viewporter::ViewporterState::new::<CompState>(&display_handle),
+ _fractional_scale_state: smithay::wayland::fractional_scale::FractionalScaleManagerState::new::<CompState>(&display_handle),
+ _cursor_shape_state: smithay::wayland::cursor_shape::CursorShapeManagerState::new::<CompState>(&display_handle),
+ idle_notifier_state: smithay::wayland::idle_notify::IdleNotifierState::new(&display_handle, event_loop.handle()),
+ _idle_inhibit_manager_state: smithay::wayland::idle_inhibit::IdleInhibitManagerState::new::<CompState>(&display_handle),
+ idle_inhibiting_surfaces: Vec::new(),
+ last_idle_notify: None,
+ window_anims: HashMap::new(),
+ last_broadcast_flags: HashMap::new(),
+ last_broadcast_workspace: None,
lock: Default::default(),
cursor_status: smithay::input::pointer::CursorImageStatus::default_named(),
- cursor_buffer: crate::cursor::make_buffer(),
+ cursor_buffers: crate::cursor::make_buffers(),
last_titlebar_click: None,
+ context_menu: None,
+ context_menu_buffer: None,
wm: wm.clone(),
surface_to_id: HashMap::new(),
id_to_window: HashMap::new(),
+ dead_layer_surfaces: HashSet::new(),
decorations: HashMap::new(),
+ border_top_decorations: HashMap::new(),
+ border_side_buffers: HashMap::new(),
+ last_synced_size: HashMap::new(),
pending: pending.clone(),
bound_keys: Rc::new(bound_keys.iter().cloned().collect::<HashSet<_>>()),
repeat_keys: Rc::new(repeat_keys.iter().cloned().collect::<HashSet<_>>()),
@@ -571,6 +1008,7 @@ impl UdevPlatform {
xwm: None,
xwayland_windows: HashMap::new(),
xwayland_pending: Vec::new(),
+ ewmh: None,
};
let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?;
@@ -578,6 +1016,28 @@ impl UdevPlatform {
std::env::set_var("WAYLAND_DISPLAY", name);
log::info!("wayland socket: {}", name.to_string_lossy());
}
+ // Otherwise this is whatever the session inherited - typically
+ // stale from a *previous* login's compositor (a shell's exported
+ // `XDG_CURRENT_DESKTOP=Hyprland` surviving into this one), since
+ // nothing else ever sets it. `xdg-desktop-portal` and any client
+ // that sniffs this value to pick a desktop-specific integration
+ // (screenshot/file-picker backends, etc.) get actively misrouted by
+ // the stale value rather than just seeing "unknown". Only affects
+ // processes spawned from here on (autostart, `srd.spawn`) - an
+ // env var set mid-process doesn't retroactively reach anything
+ // already running.
+ std::env::set_var("XDG_CURRENT_DESKTOP", "srdwm");
+
+ let ipc = match listener.socket_name().map(|n| n.to_string_lossy().into_owned()) {
+ Some(name) => match srdwm_platform::IpcServer::bind(&name) {
+ Ok(ipc) => Some(ipc),
+ Err(e) => {
+ log::warn!("control socket unavailable ({e}); srd and scripts that use it won't work");
+ None
+ }
+ },
+ None => None,
+ };
let handle = event_loop.handle();
register_drm_fd(&handle, &card)?;
@@ -590,7 +1050,7 @@ impl UdevPlatform {
log::warn!("XWayland unavailable ({e}); X11-only clients will not run");
}
- Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending })
+ Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc })
}
fn accept_clients(&mut self) -> PlatformResult<()> {
@@ -630,9 +1090,18 @@ fn bring_up_head(
// Named after the real connector (eDP-1, HDMI-A-1, ...) so clients and
// the user can tell monitors apart; `wl_output.name` is what a bar's
// per-monitor config keys off.
+ //
+ // Physical size in millimeters comes straight from EDID via the
+ // connector, not the hardcoded (0, 0) this used to be - some clients
+ // compute their own effective DPI from it (independently of the
+ // compositor's own scale factor, which srdwm always reports as 1), so
+ // reporting "no physical size at all" was live, wrong data reaching
+ // every client, not just an unfilled-in placeholder.
+ let (phys_w, phys_h) = probe.info.size().unwrap_or((0, 0));
+ let physical_mm = (phys_w as i32, phys_h as i32);
let output = Output::new(
probe.name.clone(),
- PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() },
+ PhysicalProperties { size: physical_mm.into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() },
);
let mode = OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) };
output.change_current_state(Some(mode), Some(Transform::Normal), None, Some((x_offset, 0).into()));
@@ -649,6 +1118,7 @@ fn bring_up_head(
buffers,
front: 0,
flip_pending: false,
+ ages: [0, 0],
location,
size: (width, height),
};
@@ -798,8 +1268,10 @@ fn register_session_notifier(handle: &LoopHandle<'static, CompState>, notifier:
log::warn!("udev: failed to reassert crtc on resume: {e}");
}
// Force a full repaint: contents are undefined after
- // the VT switch.
+ // the VT switch (another VT's session may have
+ // scanned out something else entirely in between).
head.flip_pending = false;
+ head.ages = [0, 0];
}
data.render_udev_frame();
}
@@ -881,8 +1353,35 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB
let source = event.source();
let mut frame = AxisFrame::new(event.time_msec()).source(source);
for axis in [Axis::Horizontal, Axis::Vertical] {
- if let Some(value) = event.amount(axis) {
- frame = frame.value(axis, value);
+ match event.amount(axis) {
+ Some(value) => frame = frame.value(axis, value),
+ // `AxisSource::Finger` (a touchpad) *requires* a stop
+ // event on the frame where the finger lifts and the
+ // axis genuinely has no more motion - see `AxisFrame::
+ // source`'s own doc comment ("Using AxisSource::Finger
+ // requires a stop event to be sent, when the user lifts
+ // off the finger"). Never sending it left every
+ // two-finger scroll gesture with no way to tell Firefox/
+ // GTK it had actually ended, which is exactly the kind
+ // of thing that reads as "scrolling doesn't work" --
+ // not "no events arrive" (discrete wheel scrolling,
+ // which needs no stop event, was never affected) but
+ // kinetic/momentum scrolling and starting a fresh
+ // gesture right after a previous one never settling.
+ None if source == AxisSource::Finger => frame = frame.stop(axis),
+ None => {}
+ }
+ // Discrete wheel steps, additional to the pixel `value`
+ // above - optional (`value` is the only event a client
+ // strictly needs), but some clients use it to distinguish
+ // "one physical click" from a smooth/high-resolution
+ // scroll, so provide it whenever the device actually
+ // reports one (real scroll wheels; never touchpads, which
+ // have no discrete steps to report - `amount_v120` is
+ // `None` for those, same guarantee `amount` gives the
+ // other way around).
+ if let Some(v120) = event.amount_v120(axis) {
+ frame = frame.v120(axis, v120 as i32);
}
}
pointer.axis(state, frame);
@@ -904,6 +1403,11 @@ impl Platform for UdevPlatform {
self.state.tick_repeat();
self.display.dispatch_clients(&mut self.state).map_err(err)?;
self.display.flush_clients().map_err(err)?;
+ if let Some(ipc) = self.ipc.as_mut() {
+ if ipc.poll(&self.state.wm) {
+ self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged);
+ }
+ }
self.state.render_udev_frame();
Ok(self.pending.borrow_mut().drain(..).collect())
}
@@ -919,9 +1423,37 @@ impl Platform for UdevPlatform {
.iter()
.enumerate()
.map(|(i, head)| {
- let (w, h) = head.size;
- let rect = srdwm_core::Rect::new(head.location.x, head.location.y, w as u32, h as u32);
+ // Shrunk by whatever a layer-shell surface (bar, dock) has
+ // reserved via `set_exclusive_zone` - reporting the full
+ // head size here otherwise means core's placement/tiling
+ // treats that strip as ordinary free space, so a new
+ // window's titlebar lands right where the bar renders on
+ // top of it, unreachable to drag. `non_exclusive_zone()` is
+ // output-local, so it's translated into this head's
+ // position in the shared global space the same way
+ // `head.location` already is.
+ let zone = layer_map_for_output(&head.output).non_exclusive_zone();
+ let rect = srdwm_core::Rect::new(
+ head.location.x + zone.loc.x,
+ head.location.y + zone.loc.y,
+ zone.size.w as u32,
+ zone.size.h as u32,
+ );
let mut m = srdwm_core::Monitor::new(i as u32, head.output.name(), rect);
+ // `Monitor::new` defaults `full_geometry` to whatever
+ // `geometry` was constructed with - correct for a monitor
+ // with no layer-shell client at all, wrong the moment one
+ // exists, since `rect` above is already zone-shrunk. Without
+ // this, `full_geometry` was silently identical to `geometry`
+ // for every real monitor this backend ever reported, which
+ // made `toggle_fullscreen`'s whole "ignore the reserved
+ // zone" design a no-op in practice: fullscreen still
+ // stopped at the bar/dock exactly like maximize does.
+ // Reported live as "fullscreen isn't actually going
+ // fullscreen" - confirmed by triggering it and reading
+ // the resulting geometry back over IPC, not just from
+ // reading this code.
+ m.full_geometry = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32);
m.primary = i == 0;
m
})
@@ -937,8 +1469,18 @@ impl Platform for UdevPlatform {
Ok(())
}
+ /// Was `wm.focus_window(window)` alone - core-only, so a caller that
+ /// only has `Platform` to go through (`crates/platform`'s `IpcServer`,
+ /// which can't reach `CompState`/real Wayland focus at all) could make
+ /// a window *look* focused (rendering already reads live core state
+ /// for the highlighted-border/titlebar-text colour) without it ever
+ /// actually receiving a keystroke - confirmed live: `srd dispatch
+ /// focus <xwayland-window-id>` changed core's own focused-window
+ /// bookkeeping but left `_NET_ACTIVE_WINDOW` at `0x0` and real
+ /// keyboard input going nowhere. `crate::input::focus_window` is the
+ /// same full path a real mouse click already goes through.
fn focus(&mut self, window: srdwm_core::WindowId) -> PlatformResult<()> {
- self.state.wm.borrow_mut().focus_window(window);
+ crate::input::focus_window(&mut self.state, window);
Ok(())
}
diff --git a/crates/wayland/src/winit.rs b/crates/wayland/src/winit.rs
index a11e263..733f4b0 100644
--- a/crates/wayland/src/winit.rs
+++ b/crates/wayland/src/winit.rs
@@ -9,25 +9,32 @@
//! what differs is only how a frame reaches a screen.
use std::cell::RefCell;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::rc::Rc;
-use std::time::Instant;
+use std::time::{Duration, Instant};
use smithay::backend::allocator::Fourcc;
-use smithay::backend::input::{AbsolutePositionEvent, ButtonState as BackendButtonState, Event as InputEventTrait, InputEvent, PointerButtonEvent};
+use smithay::backend::input::{
+ AbsolutePositionEvent, Axis, AxisSource, ButtonState as BackendButtonState, Event as InputEventTrait, InputEvent, PointerAxisEvent, PointerButtonEvent,
+};
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::gles::GlesRenderer;
+use smithay::backend::renderer::ImportDma;
use smithay::backend::winit::{self, WinitEvent, WinitEventLoop, WinitGraphicsBackend};
+use smithay::reexports::winit::dpi::LogicalSize as WinitLogicalSize;
+use smithay::reexports::winit::window::Window as WinitWindow;
use smithay::desktop::space::render_output;
-use smithay::desktop::{layer_map_for_output, Space};
+use smithay::desktop::{layer_map_for_output, PopupManager, Space};
use smithay::input::SeatState;
use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel};
+use smithay::reexports::calloop::EventLoop as CalloopEventLoop;
use smithay::reexports::wayland_server::{Client, Display, ListeningSocket};
use smithay::reexports::winit::platform::pump_events::PumpStatus;
-use smithay::utils::Transform;
+use smithay::utils::{Physical, Point, Rectangle, Scale, Size, Transform};
use smithay::wayland::compositor::CompositorState;
+use smithay::wayland::dmabuf::DmabufState;
use smithay::wayland::selection::data_device::DataDeviceState;
use smithay::wayland::selection::primary_selection::PrimarySelectionState;
use smithay::wayland::selection::wlr_data_control::DataControlState;
@@ -36,6 +43,7 @@ use smithay::wayland::shell::wlr_layer::WlrLayerShellState;
use smithay::wayland::shell::xdg::decoration::XdgDecorationState;
use smithay::wayland::shell::xdg::XdgShellState;
use smithay::wayland::shm::ShmState;
+use smithay::wayland::xdg_activation::XdgActivationState;
use srdwm_core::{Event as CoreEvent, Window as CoreWindow, WindowId, WindowManager};
use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult};
@@ -43,8 +51,9 @@ use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformRe
use crate::input::{handle_keyboard_key_event, handle_pointer_button, handle_pointer_position, last_pointer_pos};
use crate::lock::{lock_render_elements, send_lock_frame};
use crate::lock::SessionLock;
+use srdwm_platform::IpcServer;
use crate::state::{ClientState, CompState, OutputEntry};
-use crate::{err, screencopy};
+use crate::{decoration, err, screencopy};
pub struct WaylandPlatform {
display: Display<CompState>,
@@ -57,8 +66,30 @@ pub struct WaylandPlatform {
clients: Vec<Client>,
pending: Rc<RefCell<Vec<CoreEvent>>>,
wm: Rc<RefCell<WindowManager>>,
+ ipc: Option<IpcServer>,
+ /// Exists solely to host `IdleNotifierState`'s internal per-notification
+ /// timers - this backend otherwise has no `calloop` loop of its own at
+ /// all (see `ipc.rs`'s module doc comment), drawing everything instead
+ /// from `winit_events`'s manual poll and this struct's own per-tick
+ /// work. `ext_idle_notify_v1` needs a real `LoopHandle` to construct
+ /// (`smithay::wayland::idle_notify::IdleNotifierState::new`), and the
+ /// alternative - constructing the global without ever dispatching the
+ /// loop backing it - would advertise a protocol whose `idled`/`resumed`
+ /// events then simply never fire, a worse trap than the small addition
+ /// of a second, narrowly-scoped loop dispatched non-blocking once per
+ /// tick in `poll_events`.
+ idle_event_loop: CalloopEventLoop<'static, CompState>,
+ /// When the last frame was rendered - see `poll_events`' doc comment
+ /// on why this backend has to pace itself.
+ last_frame: Instant,
}
+/// Target frame budget for the winit (nested) backend's self-imposed pacing
+/// - see `poll_events`' doc comment. 60fps to match `OutputMode`'s own
+/// `refresh: 60_000` a few lines below, not because either number is
+/// special.
+const TARGET_FRAME_TIME: Duration = Duration::from_micros(1_000_000 / 60);
+
impl WaylandPlatform {
/// `bound_keys` are the config's `"Mod4+Shift+Return"`-style combo
/// strings (see `srdwm_core::key_combo_string`) - the same set the X11
@@ -67,8 +98,16 @@ impl WaylandPlatform {
pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult<Self> {
let display: Display<CompState> = Display::new().map_err(err)?;
let dh = display.handle();
-
- let (backend, winit_events) = winit::init::<GlesRenderer>().map_err(err)?;
+ // See `idle_event_loop`'s own doc comment on `WaylandPlatform`.
+ let idle_event_loop: CalloopEventLoop<'static, CompState> = CalloopEventLoop::try_new().map_err(err)?;
+
+ let (mut backend, winit_events) = winit::init_from_attributes::<GlesRenderer>(
+ WinitWindow::default_attributes()
+ .with_inner_size(WinitLogicalSize::new(1280.0, 800.0))
+ .with_title("srdwm")
+ .with_visible(true),
+ )
+ .map_err(err)?;
let size = backend.window_size();
let output = Output::new(
@@ -103,21 +142,44 @@ impl WaylandPlatform {
let data_control_state = DataControlState::new::<CompState, _>(&dh, Some(&primary_selection_state), |_| true);
let mut seat_state = SeatState::new();
let mut seat = seat_state.new_wl_seat(&dh, "seat0");
- seat.add_keyboard(Default::default(), 200, 25).map_err(err)?;
+ let system_xkb = crate::xkb_config::read();
+ let xkb_config = smithay::input::keyboard::XkbConfig {
+ rules: "",
+ model: system_xkb.model.as_deref().unwrap_or(""),
+ layout: system_xkb.layout.as_deref().unwrap_or(""),
+ variant: system_xkb.variant.as_deref().unwrap_or(""),
+ options: system_xkb.options.clone(),
+ };
+ // 600ms delay, not 200 - see `state.rs`'s `REPEAT_DELAY` doc
+ // comment for why.
+ seat.add_keyboard(xkb_config, 600, 25).map_err(err)?;
seat.add_pointer();
let mut space = Space::default();
space.map_output(&output, (0, 0));
+ // `zwp_linux_dmabuf_v1` - see `protocols.rs`'s `DmabufHandler` for
+ // the udev-vs-winit split on eager import validation, and udev.rs's
+ // matching global for why v3 (`create_global`) rather than v4's
+ // feedback variant.
+ let mut dmabuf_state = DmabufState::new();
+ dmabuf_state.create_global::<CompState>(&dh, backend.renderer().dmabuf_formats());
+
let pending = Rc::new(RefCell::new(Vec::new()));
let state = CompState {
compositor_state,
xdg_shell_state,
_xdg_decoration_state: xdg_decoration_state,
shm_state,
+ dmabuf_state,
+ xdg_activation_state: XdgActivationState::new::<CompState>(&dh),
+ _text_input_manager_state: smithay::wayland::text_input::TextInputManagerState::new::<CompState>(&dh),
+ _input_method_manager_state: smithay::wayland::input_method::InputMethodManagerState::new::<CompState, _>(&dh, |_client| true),
+ _gtk_shell_state: crate::gtk_shell::GtkShellState::new::<CompState>(&dh),
seat_state,
seat,
space,
+ popups: PopupManager::default(),
// The nested backend is inherently one output: a single window on
// the host. Multi-output is a udev/DRM concern.
outputs: vec![OutputEntry { output: output.clone(), location: (0, 0).into() }],
@@ -129,14 +191,45 @@ impl WaylandPlatform {
session_lock_state: SessionLockManagerState::new::<CompState, _>(&dh, |_| true),
_screencopy_state: screencopy::ScreencopyState::new::<CompState>(&dh),
screencopy_pending: Vec::new(),
+ _foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&dh),
+ foreign_toplevel_managers: Vec::new(),
+ foreign_toplevel_handles: HashMap::new(),
+ _workspace_state: crate::workspace::WorkspaceManagerState::new::<CompState>(&dh),
+ _output_power_state: None,
+ _gamma_control_state: None,
+ _output_management_state: crate::output_management::OutputManagementState::new::<CompState>(&dh),
+ output_managers: Vec::new(),
+ output_heads: HashMap::new(),
+ output_modes: HashMap::new(),
+ output_serial: 0,
+ last_broadcast_outputs: Vec::new(),
+ workspace_managers: Vec::new(),
+ workspace_groups: Vec::new(),
+ workspace_handles: HashMap::new(),
+ _viewporter_state: smithay::wayland::viewporter::ViewporterState::new::<CompState>(&dh),
+ _fractional_scale_state: smithay::wayland::fractional_scale::FractionalScaleManagerState::new::<CompState>(&dh),
+ _cursor_shape_state: smithay::wayland::cursor_shape::CursorShapeManagerState::new::<CompState>(&dh),
+ idle_notifier_state: smithay::wayland::idle_notify::IdleNotifierState::new(&dh, idle_event_loop.handle()),
+ _idle_inhibit_manager_state: smithay::wayland::idle_inhibit::IdleInhibitManagerState::new::<CompState>(&dh),
+ idle_inhibiting_surfaces: Vec::new(),
+ last_idle_notify: None,
+ window_anims: HashMap::new(),
+ last_broadcast_flags: HashMap::new(),
+ last_broadcast_workspace: None,
lock: SessionLock::default(),
cursor_status: smithay::input::pointer::CursorImageStatus::default_named(),
- cursor_buffer: crate::cursor::make_buffer(),
+ cursor_buffers: crate::cursor::make_buffers(),
last_titlebar_click: None,
+ context_menu: None,
+ context_menu_buffer: None,
wm: wm.clone(),
surface_to_id: HashMap::new(),
id_to_window: HashMap::new(),
+ dead_layer_surfaces: HashSet::new(),
decorations: HashMap::new(),
+ border_top_decorations: HashMap::new(),
+ border_side_buffers: HashMap::new(),
+ last_synced_size: HashMap::new(),
pending: pending.clone(),
bound_keys: Rc::new(bound_keys.iter().cloned().collect()),
repeat_keys: Rc::new(repeat_keys.iter().cloned().collect()),
@@ -147,6 +240,7 @@ impl WaylandPlatform {
xwm: None,
xwayland_windows: HashMap::new(),
xwayland_pending: Vec::new(),
+ ewmh: None,
};
let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?;
@@ -157,7 +251,32 @@ impl WaylandPlatform {
let damage_tracker = OutputDamageTracker::from_output(&output);
- Ok(Self { display, state, backend, winit_events, damage_tracker, output, listener, clients: Vec::new(), pending, wm })
+ let ipc = match listener.socket_name().map(|n| n.to_string_lossy().into_owned()) {
+ Some(name) => match IpcServer::bind(&name) {
+ Ok(ipc) => Some(ipc),
+ Err(e) => {
+ log::warn!("control socket unavailable ({e}); srd and scripts that use it won't work");
+ None
+ }
+ },
+ None => None,
+ };
+
+ Ok(Self {
+ display,
+ state,
+ backend,
+ winit_events,
+ damage_tracker,
+ output,
+ listener,
+ clients: Vec::new(),
+ pending,
+ wm,
+ ipc,
+ idle_event_loop,
+ last_frame: Instant::now(),
+ })
}
fn accept_clients(&mut self) -> PlatformResult<()> {
@@ -182,6 +301,8 @@ impl WaylandPlatform {
}
fn render_frame(&mut self) -> PlatformResult<()> {
+ self.state.tick_animations();
+ self.state.tick_dirty_broadcasts();
let size = self.backend.window_size();
let resized = self.output.current_mode().map(|m| m.size) != Some(size);
if resized {
@@ -216,15 +337,133 @@ impl WaylandPlatform {
}
let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new();
- for (&id, deco) in self.state.decorations.iter() {
- let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue };
- match MemoryRenderBufferRenderElement::from_buffer(renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) {
+ // The right-click titlebar menu, if open - pushed first so it's
+ // topmost over every window (this backend draws no cursor of its
+ // own, see this module's doc comment, so there's no "stay under
+ // the pointer" ordering concern like udev.rs's matching push has).
+ if let (Some(menu), Some(buffer)) = (self.state.context_menu.as_ref(), self.state.context_menu_buffer.as_ref()) {
+ let pos = (menu.pos.0 as f64, menu.pos.1 as f64);
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, None, Kind::Unspecified) {
Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
- Err(e) => log::warn!("failed to import titlebar buffer for window {id}: {e}"),
+ Err(e) => log::warn!("failed to import context menu buffer: {e}"),
}
}
+ // Decoration/border built per window, front-to-back (topmost
+ // first), so a window's own titlebar/border at least stay ordered
+ // consistently relative to *other* windows' decoration/border.
+ //
+ // Content deliberately still goes through `render_output`'s own
+ // `spaces` argument below (`self.state.space`), not through this
+ // loop: an earlier version of this fix pushed each window's own
+ // `Window::render_elements` output into `custom_elements` here too,
+ // in per-window stacking order, specifically to let decoration/
+ // border interleave correctly with *other* windows' content
+ // (custom_elements otherwise draws entirely above every window's
+ // content, `spaces` or not - seeing `render_output`'s source is
+ // what motivated that attempt). It was reverted: with two or more
+ // native Wayland toplevels on screen, whichever was created
+ // *first* always painted in front of later ones regardless of
+ // real focus/stacking order, reproduced consistently across three
+ // separate test windows, independent of push order, forced full
+ // redraws (`age = 0`), and buffer age - i.e. a real ordering bug
+ // in mixing multiple windows' own `render_elements` output this
+ // way, not a damage-tracking artifact. The real root cause (found
+ // later, by instrumenting a locally vendored smithay copy
+ // directly) turned out to be unrelated to content-vs-decoration
+ // mixing at all: `sync_geometry`'s `Space::map_element` call
+ // silently re-stacked windows to the top of `Space`'s own order
+ // any time position/size synced for *any* reason, independent of
+ // which rendering path was used - see `state.rs`'s
+ // `resync_stacking_order` doc comment for the full story and the
+ // actual fix. `self.state.space` stayed the content path here
+ // since reverting it was never itself wrong, just insufficient on
+ // its own.
+ let ids: Vec<WindowId> = self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect();
+ let focused = self.wm.borrow().focused_id();
+ // Windows stacked in front of whichever one border/decoration is
+ // being built right now - `ids` is already front-to-back, so this
+ // only ever needs appending to, not recomputing. Only a window's
+ // own *content* occludes correctly on its own path (via `space`,
+ // real stacking order respected); everything drawn here goes
+ // through `custom_elements`, which composites above *all* content
+ // unconditionally, so both the border strips and the titlebar
+ // bitmap below need this explicit occlusion test against it.
+ let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len());
+ for id in ids {
+ let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
+ // `w.geometry` is the animation's target, not necessarily where
+ // the window is actually drawn this frame - see the matching
+ // comment in `udev.rs`'s render loop for the full story
+ // (reported live as the border "not flush" with the window
+ // during an animated maximize/fullscreen/open-slide transition).
+ let geom = self.state.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry);
+ if let Some(deco) = self.state.decorations.get(&id) {
+ // Fragment-clipped, same as udev.rs's matching titlebar
+ // push - see that comment for why all-or-nothing (skip
+ // only once *fully* covered) wasn't enough: a titlebar
+ // only partially covered, the common case for cascaded
+ // windows, still bled through the covered part.
+ let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT);
+ for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) {
+ let pos = (fragment.x as f64, fragment.y as f64);
+ let src = Rectangle::new(
+ Point::from(((fragment.x - titlebar_rect.x) as f64, (fragment.y - titlebar_rect.y) as f64)),
+ Size::from((fragment.width as f64, fragment.height as f64)),
+ );
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, deco, None, Some(src), None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("failed to import titlebar buffer for window {id}: {e}"),
+ }
+ }
+ }
+ // Border strips sit entirely outside `geometry` (see
+ // `decoration::border_strips`), so they never overlap this same
+ // window's own decoration/content pixels - draw order relative
+ // to those doesn't matter, only relative to other windows'.
+ if w.border_width > 0 {
+ let color = crate::state::effective_border_color(w.border_color, focused == Some(id));
+ let strips = decoration::border_strips(geom, w.border_width);
+ // Strip 0 (top) is rounded to match the titlebar underneath
+ // it - see `render_border_top`'s doc comment - so it's a
+ // cached bitmap (rebuilt only in `redraw_decoration_buffer`,
+ // same as the titlebar itself), not rasterized fresh here
+ // every frame; the other three don't touch a rounded corner
+ // and stay persistent solid-colour buffers instead - see
+ // `elements::border_side_render_element`'s doc comment for
+ // why a per-frame rebuild of either was a real, continuous
+ // cost, not a cosmetic one. Not fragment-clipped like the
+ // other three below - see the matching comment in
+ // `udev.rs` for why the top strip only gets the cheaper
+ // all-or-nothing occlusion check.
+ if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() {
+ if let Some(buffer) = self.state.border_top_decorations.get(&id) {
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, (strips[0].x as f64, strips[0].y as f64), buffer, None, None, None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("failed to import top border buffer for window {id}: {e}"),
+ }
+ }
+ }
+ let pool = self.state.border_side_buffers.entry(id).or_default();
+ let mut buf_index = 0;
+ for strip in &strips[1..] {
+ if strip.width == 0 || strip.height == 0 {
+ continue;
+ }
+ for fragment in crate::elements::visible_border_fragments(*strip, &occluders) {
+ let buf = crate::elements::border_fragment_buffer(pool, buf_index);
+ buf_index += 1;
+ custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(buf, fragment, color, (0, 0))));
+ }
+ }
+ }
+ occluders.push(geom);
+ }
+ // Single output at the global origin - no offset to subtract, see
+ // `elements.rs`'s doc comment on why udev.rs's per-head call does.
+ let popup_targets = crate::elements::popup_targets(&self.state);
+ custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, renderer, (0, 0)));
- render_output(
+ let result = render_output(
&self.output,
renderer,
&mut framebuffer,
@@ -236,9 +475,69 @@ impl WaylandPlatform {
[0.05, 0.05, 0.08, 1.0],
)
.map_err(err)?;
+ let damage_rects: Vec<Rectangle<i32, Physical>> = result.damage.cloned().unwrap_or_default();
+ let has_damage = !damage_rects.is_empty();
drop(framebuffer);
- self.backend.submit(None).map_err(err)?;
- self.state.space.elements().for_each(|w| w.send_frame(&self.output, self.state.start_time.elapsed(), None, |_, _| Some(self.output.clone())));
+ // Both the buffer swap and the frame-callback notification are
+ // conditional on real damage now - this used to run
+ // unconditionally on every call to this function (every ~16ms
+ // regardless of activity), which told every window it could render
+ // its next frame whether or not the screen had actually changed.
+ // Any client using the standard wait-for-frame-callback render
+ // pattern (most of them) had no reason not to redraw at whatever
+ // rate this loop cycled, forever - confirmed live on the udev
+ // backend: wezterm-gui pinned at 140%+ CPU sitting on a fully idle,
+ // unchanged terminal, from the identical bug there.
+ //
+ // That output-wide gate wasn't enough on its own: cursor motion
+ // alone damages the small region around the pointer, which still
+ // marked the *whole output* damaged and sent every mapped window a
+ // callback regardless of whether the cursor was anywhere near it.
+ // `windows_touched_by_damage` narrows this to windows the actual
+ // damage rectangles overlap - see its doc comment in elements.rs.
+ if has_damage {
+ self.backend.submit(None).map_err(err)?;
+ let scale = Scale::from(self.output.current_scale().fractional_scale());
+ let now = self.state.start_time.elapsed();
+ for w in crate::elements::windows_touched_by_damage(&self.state.space, &damage_rects, scale) {
+ w.send_frame(&self.output, now, None, |_, _| Some(self.output.clone()));
+ }
+ }
+ // Deliberately outside `if has_damage`: the whole point of
+ // `always_notify` is covering the case where the output has *no*
+ // damage at all (a fully idle desktop, cursor not moving) but the
+ // focused/hovered window still has a pending callback it needs
+ // answered to unblock an input-driven redraw. Nesting this inside
+ // `if has_damage` (the first version of this fix) meant it only
+ // ever ran on a tick that already had damage from something else
+ // happening - i.e. never in the exact scenario it exists for.
+ // Reported live as clicks in Firefox still doing nothing at all,
+ // not just intermittently, after the first version of this fix.
+ {
+ let pointer_pos = last_pointer_pos(&self.state);
+ let now = self.state.start_time.elapsed();
+ let wm = self.wm.borrow();
+ let always_notify = [wm.focused_id(), wm.window_at(pointer_pos.x as i32, pointer_pos.y as i32)];
+ drop(wm);
+ for w in always_notify.into_iter().flatten().filter_map(|id| self.state.id_to_window.get(&id)) {
+ w.send_frame(&self.output, now, None, |_, _| Some(self.output.clone()));
+ }
+ }
+ // Layer-shell surfaces get their callback every pass, unconditionally
+ // - NOT folded into the `has_damage` gate above. See the matching
+ // (much longer) comment in udev.rs's `render_udev_frame`: many
+ // layer-shell clients (GTK4/AGS among them) drive their entire
+ // repaint loop off frame callbacks with no independent timer
+ // fallback, so withholding the callback until *something* on the
+ // desktop happens to produce damage deadlocks them permanently
+ // after their first frame - confirmed live, AGS and waybar both
+ // froze exactly this way. Toplevel windows keep the damage gate
+ // (that's what fixed the wezterm-gui CPU-burn bug); layer surfaces
+ // are few, cheap to redraw, and are exactly the periodic-UI-chrome
+ // case frame callbacks exist to pace.
+ for layer in layer_map_for_output(&self.output).layers() {
+ layer.send_frame(&self.output, self.state.start_time.elapsed(), None, |_, _| Some(self.output.clone()));
+ }
// Screencopy is serviced *after* the on-screen frame is submitted,
// into its own offscreen buffer - never by reading back the window
@@ -279,7 +578,7 @@ impl WaylandPlatform {
let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new();
for (&id, deco) in self.state.decorations.iter() {
- let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue };
+ let Some(geom) = self.wm.borrow().visible_windows().find(|w| w.id == id).map(|w| w.geometry) else { continue };
if let Ok(elem) = MemoryRenderBufferRenderElement::from_buffer(renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) {
custom_elements.push(crate::elements::OverlayElement::Memory(elem));
}
@@ -321,6 +620,33 @@ fn handle_winit_event(state: &mut CompState, output: &Output, event: WinitEvent,
let pressed = event.state() == BackendButtonState::Pressed;
handle_pointer_button(state, pos, button, pressed, event.time_msec());
}
+ // This backend had no scroll handling at all - `InputEvent::
+ // PointerAxis` fell into the catch-all below and was silently
+ // dropped, unconditionally, on every device. Same forwarding as
+ // `udev.rs`'s equivalent (see its own comment for the `stop()`/
+ // `v120()` reasoning); duplicated rather than shared since the two
+ // backends' `InputEvent` generic parameters differ and there's no
+ // shared event type to write one function against.
+ WinitEvent::Input(InputEvent::PointerAxis { event }) => {
+ if crate::input::handle_workspace_scroll(state, &event) {
+ return;
+ }
+ let Some(pointer) = state.seat.get_pointer() else { return };
+ let source = event.source();
+ let mut frame = smithay::input::pointer::AxisFrame::new(event.time_msec()).source(source);
+ for axis in [Axis::Horizontal, Axis::Vertical] {
+ match event.amount(axis) {
+ Some(value) => frame = frame.value(axis, value),
+ None if source == AxisSource::Finger => frame = frame.stop(axis),
+ None => {}
+ }
+ if let Some(v120) = event.amount_v120(axis) {
+ frame = frame.v120(axis, v120 as i32);
+ }
+ }
+ pointer.axis(state, frame);
+ pointer.frame(state);
+ }
WinitEvent::Resized { .. } => {}
_ => {}
}
@@ -331,6 +657,31 @@ impl Platform for WaylandPlatform {
PlatformKind::Wayland
}
+ /// **Self-paced, deliberately**: nothing else in this backend ever
+ /// blocks. `pump_winit`'s underlying `dispatch_new_events` polls
+ /// (returns immediately either way), and smithay's winit backend
+ /// hardcodes `vsync: false` on the EGL surface it creates
+ /// (`init_from_attributes_with_gl_attr` in smithay 0.7.0's own
+ /// `backend/winit/mod.rs` - true of *every* entry point into that
+ /// module, including the one this backend used before it needed custom
+ /// `WindowAttributes`, so this was never introduced by that switch).
+ /// `swap_buffers` therefore returns as soon as the GPU accepts the
+ /// frame, with no wait for the next display refresh at all. Before this
+ /// fix, that meant `poll_events` -> `render_frame` -> full render +
+ /// `swap_buffers` ran back-to-back with nothing pacing the `while
+ /// running.get()` loop in `main.rs` between iterations - confirmed
+ /// live: an idle nested instance, zero windows, sat at a sustained
+ /// ~52% of one core (`ps -o %cpu`), because it was rendering and
+ /// presenting a full frame as fast as the CPU/GPU could physically
+ /// cycle, forever, whether or not anything on screen had changed.
+ /// Fixed by giving `idle_event_loop.dispatch` (already called every
+ /// tick to service `ext_idle_notify_v1`'s timers, see its field doc
+ /// comment) a real timeout instead of always `Duration::ZERO`: the
+ /// remaining budget until `TARGET_FRAME_TIME` has elapsed since the
+ /// last frame, clamped to zero once that budget is already spent. This
+ /// reuses the one blocking wait this backend already has rather than
+ /// adding a second, separate `thread::sleep`, and still services any
+ /// idle-notify timer that comes due sooner than a full frame away.
fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> {
self.accept_clients()?;
let closed = self.pump_winit()?;
@@ -341,14 +692,35 @@ impl Platform for WaylandPlatform {
self.state.tick_repeat();
self.display.dispatch_clients(&mut self.state).map_err(err)?;
self.display.flush_clients().map_err(err)?;
+ if let Some(ipc) = self.ipc.as_mut() {
+ if ipc.poll(&self.wm) {
+ self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged);
+ }
+ }
+ let wait = TARGET_FRAME_TIME.saturating_sub(self.last_frame.elapsed());
+ let _ = self.idle_event_loop.dispatch(Some(wait), &mut self.state);
+ self.last_frame = Instant::now();
self.render_frame()?;
Ok(self.pending.borrow_mut().drain(..).collect())
}
fn monitors(&mut self) -> PlatformResult<Vec<srdwm_core::Monitor>> {
- let size = self.backend.window_size();
+ // Shrunk by any layer-shell exclusive zone - see the matching
+ // comment in `udev.rs`'s `monitors()`. This backend is always a
+ // single output at the global origin, so the output-local zone
+ // rectangle already is the usable global-space rect.
+ let zone = layer_map_for_output(&self.output).non_exclusive_zone();
Ok(vec![{
- let mut m = srdwm_core::Monitor::new(0, "winit", srdwm_core::Rect::new(0, 0, size.w as u32, size.h as u32));
+ let rect = srdwm_core::Rect::new(zone.loc.x, zone.loc.y, zone.size.w as u32, zone.size.h as u32);
+ let mut m = srdwm_core::Monitor::new(0, "winit", rect);
+ // Same fix as `udev.rs`'s matching function: `Monitor::new`
+ // defaults `full_geometry` to `geometry`, which is already
+ // zone-shrunk here - without this, `toggle_fullscreen` had no
+ // way to actually cover a bar/dock's reserved strip, since the
+ // "true full rect" it targets was silently identical to the
+ // "usable, shrunk rect" `toggle_maximize` targets.
+ let full = self.backend.window_size();
+ m.full_geometry = srdwm_core::Rect::new(0, 0, full.w as u32, full.h as u32);
m.primary = true;
m
}])
@@ -364,8 +736,11 @@ impl Platform for WaylandPlatform {
Ok(())
}
+ /// See `udev.rs`'s matching impl for why this has to go through
+ /// `crate::input::focus_window` (the same path a real mouse click
+ /// already uses) rather than only touching core state.
fn focus(&mut self, window: WindowId) -> PlatformResult<()> {
- self.state.wm.borrow_mut().focus_window(window);
+ crate::input::focus_window(&mut self.state, window);
Ok(())
}
diff --git a/crates/wayland/src/workspace.rs b/crates/wayland/src/workspace.rs
new file mode 100644
index 0000000..ce9cd04
--- /dev/null
+++ b/crates/wayland/src/workspace.rs
@@ -0,0 +1,251 @@
+//! `ext-workspace-v1`: enumerates srdwm's workspaces (name, active/urgent/
+//! hidden state) to any client that binds it, and lets that client request
+//! activation - the workspace pill/switcher half of a dock, requested
+//! alongside `foreign_toplevel.rs` (see `docs/PANEL_SUPPORT_TODO.md`'s P1
+//! list). smithay 0.7 has no built-in helper for this protocol either;
+//! hand-written against the raw `wayland-protocols` server bindings, same
+//! pattern as `screencopy.rs`/`foreign_toplevel.rs`.
+//!
+//! srdwm has exactly one flat, global list of workspaces shared by every
+//! output (`WindowManager::current_workspace`/`workspaces()`), not a
+//! separate set per monitor - so this always advertises exactly one
+//! `ext_workspace_group_handle_v1`, entered by every output, containing
+//! every workspace. A compositor with per-output workspaces would need one
+//! group per output instead; that's not this one.
+//!
+//! Only `activate` is implemented as a request: srdwm's workspaces are a
+//! fixed, config-defined set (created once at startup, not created/removed/
+//! reassigned at runtime), so `create_workspace`/`remove`/`assign` have
+//! nothing meaningful to do and their capability bits are simply not
+//! advertised - a client is expected to hide the UI for a request whose
+//! capability bit is unset, per the protocol's own `capabilities` event
+//! doc comment, rather than send it and be ignored.
+//!
+//! `deactivate` is also not advertised: srdwm always has exactly one
+//! current workspace, there is no "no workspace active" state to request.
+
+use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
+use wayland_protocols::ext::workspace::v1::server::ext_workspace_group_handle_v1::{self, ExtWorkspaceGroupHandleV1, GroupCapabilities};
+use wayland_protocols::ext::workspace::v1::server::ext_workspace_handle_v1::{self, ExtWorkspaceHandleV1, State, WorkspaceCapabilities};
+use wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::{self, ExtWorkspaceManagerV1};
+
+use srdwm_core::WorkspaceId;
+
+use crate::state::CompState;
+
+const PROTOCOL_VERSION: u32 = 1;
+
+pub struct WorkspaceManagerState {
+ _global: smithay::reexports::wayland_server::backend::GlobalId,
+}
+
+impl WorkspaceManagerState {
+ pub fn new<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<ExtWorkspaceManagerV1, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, ExtWorkspaceManagerV1, _>(PROTOCOL_VERSION, ()) }
+ }
+}
+
+pub struct WorkspaceHandleData {
+ workspace: WorkspaceId,
+}
+
+impl GlobalDispatch<ExtWorkspaceManagerV1, ()> for CompState {
+ fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ExtWorkspaceManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ let manager = data_init.init(manager, ());
+
+ let Ok(group) = client.create_resource::<ExtWorkspaceGroupHandleV1, (), CompState>(dh, manager.version(), ()) else {
+ return;
+ };
+ manager.workspace_group(&group);
+ // Nothing here is dynamically created/removed/reassigned - see the
+ // module doc comment on why this is always empty.
+ group.capabilities(GroupCapabilities::empty());
+ // Every output enters the one group, since workspaces span all of
+ // them. Only reaches outputs this client has *already* bound
+ // `wl_output` for - a client binding `ext_workspace_manager_v1`
+ // before any `wl_output` global would see no `output_enter` here,
+ // and none later either, since nothing currently re-checks this on
+ // a subsequent `wl_output` bind. Real clients bind their globals up
+ // front, so not fixing that ordering-dependency is an accepted gap
+ // rather than a deliberate design choice.
+ for output in state.outputs() {
+ for wl_output in output.client_outputs(client) {
+ group.output_enter(&wl_output);
+ }
+ }
+
+ let ids: Vec<WorkspaceId> = state.wm.borrow().workspaces().iter().map(|w| w.id).collect();
+ for id in ids {
+ announce_workspace(state, &manager, &group, id, client, dh);
+ }
+ manager.done();
+
+ state.workspace_managers.push(manager);
+ state.workspace_groups.push(group);
+ }
+}
+
+impl Dispatch<ExtWorkspaceManagerV1, ()> for CompState {
+ fn request(state: &mut Self, _client: &Client, manager: &ExtWorkspaceManagerV1, request: ext_workspace_manager_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) {
+ match request {
+ // Every request below is handled the moment it arrives rather
+ // than batched, so there is nothing left for `commit` itself to
+ // flush - a plain acknowledgement.
+ ext_workspace_manager_v1::Request::Commit => {}
+ ext_workspace_manager_v1::Request::Stop => {
+ manager.finished();
+ state.workspace_managers.retain(|m| m != manager);
+ }
+ _ => {}
+ }
+ }
+
+ fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, manager: &ExtWorkspaceManagerV1, _data: &()) {
+ state.workspace_managers.retain(|m| m != manager);
+ }
+}
+
+impl Dispatch<ExtWorkspaceGroupHandleV1, ()> for CompState {
+ fn request(state: &mut Self, _client: &Client, group: &ExtWorkspaceGroupHandleV1, request: ext_workspace_group_handle_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) {
+ // `create_workspace` is deliberately unimplemented - its
+ // capability bit is never advertised (see the module doc comment),
+ // so a well-behaved client never sends it; ignored either way
+ // rather than erroring a client that sends it anyway.
+ if let ext_workspace_group_handle_v1::Request::Destroy = request {
+ state.workspace_groups.retain(|g| g != group);
+ }
+ }
+
+ fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, group: &ExtWorkspaceGroupHandleV1, _data: &()) {
+ state.workspace_groups.retain(|g| g != group);
+ }
+}
+
+impl Dispatch<ExtWorkspaceHandleV1, WorkspaceHandleData> for CompState {
+ fn request(state: &mut Self, _client: &Client, _handle: &ExtWorkspaceHandleV1, request: ext_workspace_handle_v1::Request, data: &WorkspaceHandleData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) {
+ if let ext_workspace_handle_v1::Request::Activate = request {
+ let mut wm = state.wm.borrow_mut();
+ if wm.current_workspace() != data.workspace {
+ wm.switch_workspace(data.workspace);
+ drop(wm);
+ // `switch_workspace` alone only updates core's own state --
+ // nothing re-renders or shows/hides windows for the new
+ // workspace without `main.rs`'s `sync()` running, which
+ // only happens when a polled event sets `dirty`. Pushing
+ // `WorkspaceChanged` is what makes that happen; see its
+ // definition in `srdwm_core::event` for the fuller story
+ // (this request path has the exact same problem the
+ // pre-existing `SUPER+scroll` workspace gesture already
+ // had, found while wiring this up).
+ state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged);
+ broadcast_active_workspace(state);
+ }
+ }
+ // Deactivate/Assign/Remove: not advertised as available (see the
+ // module doc comment), so real clients don't send them. Destroy
+ // ends this protocol object, handled by `destroyed` below.
+ }
+
+ fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, handle: &ExtWorkspaceHandleV1, data: &WorkspaceHandleData) {
+ if let Some(handles) = state.workspace_handles.get_mut(&data.workspace) {
+ handles.retain(|h| h != handle);
+ }
+ }
+}
+
+/// Packs a workspace's current flags into `ext_workspace_handle_v1.state`'s
+/// wire format. srdwm's workspaces are never `Urgent` (nothing in this
+/// codebase has an "urgent"/attention-request concept for a workspace) or
+/// `Hidden` (all of them are always real, switchable workspaces, never an
+/// internal/scratch one that should stay out of a switcher UI) - only
+/// `Active` ever varies.
+fn workspace_state(active: bool) -> State {
+ if active {
+ State::Active
+ } else {
+ State::empty()
+ }
+}
+
+fn announce_workspace(state: &mut CompState, manager: &ExtWorkspaceManagerV1, group: &ExtWorkspaceGroupHandleV1, id: WorkspaceId, client: &Client, dh: &DisplayHandle) {
+ let Ok(handle) = client.create_resource::<ExtWorkspaceHandleV1, WorkspaceHandleData, CompState>(dh, manager.version(), WorkspaceHandleData { workspace: id }) else {
+ return;
+ };
+ manager.workspace(&handle);
+ group.workspace_enter(&handle);
+
+ let Some(w) = state.wm.borrow().workspaces().iter().find(|w| w.id == id).cloned() else { return };
+ let active = state.wm.borrow().current_workspace() == id;
+ handle.name(w.name);
+ // 1D: srdwm numbers workspaces without any geometric/grid arrangement
+ // - see this event's own doc comment on why a flat index is the
+ // correct thing to send here, not a guess at one.
+ handle.coordinates((id as u32).to_ne_bytes().to_vec());
+ handle.state(workspace_state(active));
+ handle.capabilities(WorkspaceCapabilities::Activate);
+
+ state.workspace_handles.entry(id).or_default().push(handle);
+}
+
+/// Called after `switch_workspace` actually changes which workspace is
+/// current - re-sends `state` (and `done`) for every workspace handle
+/// across every bound client, so a switcher's active-workspace highlight
+/// follows real changes instead of only ever reflecting whatever was
+/// active when each handle was created. Broadcasts to *all* workspaces
+/// (not just the old/new pair) since, unlike `foreign_toplevel`'s
+/// per-window handle lookup, resolving "which workspace lost `Active`" has
+/// no direct handle to key off here - this only fires on a real
+/// workspace switch, not on every input event, so the cost is one small
+/// loop per switch, not per frame.
+pub(crate) fn broadcast_active_workspace(state: &mut CompState) {
+ let current = state.wm.borrow().current_workspace();
+ let ids: Vec<WorkspaceId> = state.workspace_handles.keys().copied().collect();
+ for id in ids {
+ let Some(handles) = state.workspace_handles.get(&id).cloned() else { continue };
+ for handle in &handles {
+ handle.state(workspace_state(id == current));
+ }
+ }
+ for manager in state.workspace_managers.clone() {
+ manager.done();
+ }
+}
+
+/// Calls `broadcast_active_workspace` only when the current workspace has
+/// actually changed since the last broadcast (by *any* means), called once
+/// per frame from `CompState::tick_dirty_broadcasts`.
+///
+/// `input.rs`'s `SUPER+scroll` cycle gesture and this protocol's own
+/// `activate` request both already call `broadcast_active_workspace`
+/// directly. The gap, same shape and same root cause as `foreign_toplevel::
+/// broadcast_dirty_state`'s (see its doc comment): the Lua `srd.workspace.
+/// next()`/`.prev()`/`.switch()` API changes `WindowManager` state through
+/// `crates/config`, which has no reachable path to this Wayland-specific
+/// module, so a switch driven from a keybinding went stale in a dock's
+/// workspace pill until some unrelated scroll or `activate` request
+/// happened to resync it. `broadcast_active_workspace` itself is not safe
+/// to call unconditionally every frame - it always re-sends `state` to
+/// every handle and `done` to every manager, real protocol traffic, not
+/// just a cheap comparison - so this gates it on an actual change first.
+pub(crate) fn broadcast_dirty_active(state: &mut CompState) {
+ let current = state.wm.borrow().current_workspace();
+ if state.last_broadcast_workspace == Some(current) {
+ return;
+ }
+ state.last_broadcast_workspace = Some(current);
+ broadcast_active_workspace(state);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn only_the_active_workspace_gets_the_active_bit() {
+ assert_eq!(workspace_state(true), State::Active);
+ assert_eq!(workspace_state(false), State::empty());
+ }
+}
diff --git a/crates/wayland/src/xkb_config.rs b/crates/wayland/src/xkb_config.rs
new file mode 100644
index 0000000..5b16904
--- /dev/null
+++ b/crates/wayland/src/xkb_config.rs
@@ -0,0 +1,102 @@
+//! Reads the system's real keyboard layout/model/options so both backends'
+//! `seat.add_keyboard()` calls actually use them.
+//!
+//! `smithay::input::keyboard::XkbConfig::default()` - what both backends
+//! passed unconditionally before this - resolves any field left as `""`/
+//! `None` via the `XKB_DEFAULT_*` environment variables, per xkbcommon's
+//! own documented behavior (see its doc comment). That covers a session
+//! that actually sets those variables, but this machine doesn't: the real
+//! configuration lives in `/etc/X11/xorg.conf.d/00-keyboard.conf`, written
+//! by `systemd-localed` from `localectl`, which nothing was ever reading.
+//! Concretely: `Option "XkbOptions" "terminate:ctrl_alt_bksp"` was silently
+//! dropped, along with the model (`pc105+inet`), regardless of what
+//! `localectl status` actually reports.
+//!
+//! Deliberately fails soft, field by field: a missing file, an unreadable
+//! one, or a field just not present in it all fall through to the same
+//! `""`/`None` `Default::default()` already used, not an error - this is
+//! strictly additive over today's behavior, never worse.
+
+use std::collections::HashMap;
+
+/// Parsed fields from the standard `XkbLayout`/`XkbModel`/`XkbVariant`/
+/// `XkbOptions` `Option "..." "..."` lines `systemd-localed` writes.
+/// Anything not found is `None`, which is exactly what an empty-string
+/// `XkbConfig` field already meant.
+#[derive(Default)]
+pub(crate) struct SystemXkbConfig {
+ pub(crate) model: Option<String>,
+ pub(crate) layout: Option<String>,
+ pub(crate) variant: Option<String>,
+ pub(crate) options: Option<String>,
+}
+
+const KEYBOARD_CONF_PATH: &str = "/etc/X11/xorg.conf.d/00-keyboard.conf";
+
+pub(crate) fn read() -> SystemXkbConfig {
+ let Ok(content) = std::fs::read_to_string(KEYBOARD_CONF_PATH) else {
+ return SystemXkbConfig::default();
+ };
+ let fields = parse(&content);
+ SystemXkbConfig {
+ model: fields.get("XkbModel").cloned(),
+ layout: fields.get("XkbLayout").cloned(),
+ variant: fields.get("XkbVariant").cloned(),
+ options: fields.get("XkbOptions").cloned(),
+ }
+}
+
+/// Extracts `Option "Name" "value"` lines into a name -> value map. Not a
+/// general X11 config parser - this file has exactly one `InputClass`
+/// section with a handful of `Option` lines in that fixed form, always
+/// machine-written, so a line-by-line scan for that one pattern is all
+/// this needs.
+fn parse(content: &str) -> HashMap<String, String> {
+ let mut fields = HashMap::new();
+ for line in content.lines() {
+ let line = line.trim();
+ let Some(rest) = line.strip_prefix("Option") else { continue };
+ let quoted: Vec<&str> = rest.split('"').filter(|s| !s.trim().is_empty()).collect();
+ if let [name, value] = quoted[..] {
+ fields.insert(name.to_string(), value.to_string());
+ }
+ }
+ fields
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_a_real_systemd_localed_file() {
+ let content = r#"
+Section "InputClass"
+ Identifier "system-keyboard"
+ MatchIsKeyboard "on"
+ Option "XkbLayout" "us"
+ Option "XkbModel" "pc105+inet"
+ Option "XkbOptions" "terminate:ctrl_alt_bksp"
+EndSection
+"#;
+ let fields = parse(content);
+ assert_eq!(fields.get("XkbLayout").map(String::as_str), Some("us"));
+ assert_eq!(fields.get("XkbModel").map(String::as_str), Some("pc105+inet"));
+ assert_eq!(fields.get("XkbOptions").map(String::as_str), Some("terminate:ctrl_alt_bksp"));
+ }
+
+ #[test]
+ fn ignores_unrelated_lines_without_panicking() {
+ let content = "Section \"InputClass\"\nIdentifier \"system-keyboard\"\nMatchIsKeyboard \"on\"\nEndSection\n";
+ assert!(parse(content).is_empty());
+ }
+
+ #[test]
+ fn missing_file_yields_all_none_not_an_error() {
+ // read() itself isn't unit-testable without touching the real
+ // filesystem path, but the fallback behavior it guarantees is
+ // exactly SystemXkbConfig::default() - covered by construction.
+ let cfg = SystemXkbConfig::default();
+ assert!(cfg.model.is_none() && cfg.layout.is_none() && cfg.variant.is_none() && cfg.options.is_none());
+ }
+}
diff --git a/crates/wayland/src/xwayland.rs b/crates/wayland/src/xwayland.rs
index ac71492..f18a485 100644
--- a/crates/wayland/src/xwayland.rs
+++ b/crates/wayland/src/xwayland.rs
@@ -75,7 +75,11 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm
XWaylandEvent::Ready { x11_socket, display_number } => {
log::info!("XWayland ready on display :{display_number}");
match X11Wm::start_wm(handle_for_ready.clone(), x11_socket, client.clone()) {
- Ok(wm) => data.xwm = Some(wm),
+ Ok(wm) => {
+ data.xwm = Some(wm);
+ fix_wm_name(display_number);
+ data.ewmh = EwmhState::connect(display_number);
+ }
Err(e) => log::error!("failed to start X11 window manager for XWayland: {e}"),
}
}
@@ -85,6 +89,289 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm
Ok(())
}
+/// Overwrites `_NET_WM_NAME` on XWayland's WM-check window from "Smithay X
+/// WM" to "srdwm".
+///
+/// `X11Wm::start_wm` hardcodes that string with no override hook exposed --
+/// no public method on `X11Wm`, and `wm_window`/its connection are private
+/// fields, so it can't be reached through smithay's API at all. Every X11
+/// client that asks "who is the window manager" (`xprop`, `wmctrl`,
+/// `xdotool`, fetch tools, app-compat shims that branch on WM identity)
+/// gets told the name of the *library*, not the compositor - which is
+/// actively misleading, not just cosmetic: it broke a shell function that
+/// resolved the WM's process name from this exact property to kill it on
+/// logout, `pkill`ing "Smithay" and matching nothing.
+///
+/// Worked around by opening a second, independent X11 connection of our
+/// own to the same XWayland display - exactly what `xprop` itself would
+/// do - and rewriting the property directly. `_NET_SUPPORTING_WM_CHECK`
+/// (which `start_wm` does set correctly) is how a plain client is meant to
+/// find the WM-check window in the first place, so reading it back off the
+/// root window rather than assuming a window ID keeps this from silently
+/// going stale if smithay ever changes how it allocates that window.
+fn fix_wm_name(display_number: u32) {
+ use smithay::reexports::x11rb::connection::Connection;
+ use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _, PropMode};
+ use smithay::reexports::x11rb::rust_connection::RustConnection;
+ use smithay::reexports::x11rb::wrapper::ConnectionExt as _;
+
+ let display = format!(":{display_number}");
+ let (conn, screen_num) = match RustConnection::connect(Some(&display)) {
+ Ok(c) => c,
+ Err(e) => {
+ log::warn!("xwayland: couldn't open a second connection to fix _NET_WM_NAME: {e}");
+ return;
+ }
+ };
+ let root = conn.setup().roots[screen_num].root;
+
+ let intern = |name: &str| -> Option<u32> { conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom) };
+ let (Some(supporting_wm_check), Some(net_wm_name), Some(utf8_string)) =
+ (intern("_NET_SUPPORTING_WM_CHECK"), intern("_NET_WM_NAME"), intern("UTF8_STRING"))
+ else {
+ log::warn!("xwayland: couldn't intern EWMH atoms to fix _NET_WM_NAME");
+ return;
+ };
+
+ let reply = conn.get_property(false, root, supporting_wm_check, AtomEnum::WINDOW, 0, 1).ok().and_then(|c| c.reply().ok());
+ let wm_window = reply.as_ref().and_then(|r| r.value32()).and_then(|mut it| it.next());
+ let Some(wm_window) = wm_window else {
+ log::warn!("xwayland: _NET_SUPPORTING_WM_CHECK unset on the XWayland root; can't fix _NET_WM_NAME");
+ return;
+ };
+
+ if let Err(e) = conn.change_property8(PropMode::REPLACE, wm_window, net_wm_name, utf8_string, b"srdwm") {
+ log::warn!("xwayland: failed to set _NET_WM_NAME: {e}");
+ return;
+ }
+ let _ = conn.flush();
+}
+
+/// Keeps `_NET_ACTIVE_WINDOW`/`_NET_CLIENT_LIST`/`_NET_CLIENT_LIST_STACKING`
+/// on the XWayland root window up to date.
+///
+/// srdwm declares all three in `_NET_SUPPORTED` (smithay's `X11Wm` sets that
+/// part up on its own), but never actually wrote them: `_NET_ACTIVE_WINDOW`
+/// stayed `0x0` and `_NET_CLIENT_LIST` stayed empty regardless of what was
+/// focused or mapped. Confirmed this is not something `X11Wm` does for us
+/// automatically - it only updates `_NET_ACTIVE_WINDOW` in response to a
+/// real X11 `FocusIn`/`FocusOut` event on the window, which requires an
+/// actual `SetInputFocus` request to have been issued in the first place,
+/// and nothing in this codebase ever issues one (Wayland keyboard focus and
+/// X11 input focus are separate things; only the former was ever set). The
+/// practical effect: any X11-aware client trying to answer "what's the
+/// focused window" or "what windows exist" - `xdotool`, `wmctrl`, and
+/// (per a downstream report) an AGS global-menu widget resolving the
+/// focused window to query its DBusMenu registrar - got nothing.
+///
+/// Rather than depend on `X11Wm`'s `FocusIn`-triggered path (which would
+/// also need us to issue real `SetInputFocus` requests, itself a bigger
+/// change with its own risk of fighting Wayland focus), this writes both
+/// properties directly, from srdwm's own already-authoritative focus and
+/// window-list state - exactly the "no new protocol needed" shape a
+/// real EWMH-maintaining WM uses. Same reasoning as `fix_wm_name` for using
+/// a second, independent connection rather than reaching into `X11Wm`'s
+/// private one: there is no public accessor for it.
+pub(crate) struct EwmhState {
+ conn: smithay::reexports::x11rb::rust_connection::RustConnection,
+ root: u32,
+ net_active_window: u32,
+ net_client_list: u32,
+ net_client_list_stacking: u32,
+ /// Global-menu atoms - see `read_global_menu`. Individually optional
+ /// (unlike the EWMH atoms above): a server old enough, or configured
+ /// oddly enough, to not know these names is still a fully functional
+ /// X server for everything else this module does, so a failure to
+ /// intern any one of them just means that field never resolves rather
+ /// than aborting `connect` entirely.
+ gtk_unique_bus_name: Option<u32>,
+ gtk_application_object_path: Option<u32>,
+ gtk_window_object_path: Option<u32>,
+ gtk_menubar_object_path: Option<u32>,
+ gtk_app_menu_object_path: Option<u32>,
+ unity_object_path: Option<u32>,
+}
+
+impl EwmhState {
+ fn connect(display_number: u32) -> Option<Self> {
+ use smithay::reexports::x11rb::connection::Connection;
+ use smithay::reexports::x11rb::protocol::xproto::ConnectionExt as _;
+ use smithay::reexports::x11rb::rust_connection::RustConnection;
+
+ let display = format!(":{display_number}");
+ let (conn, screen_num) = match RustConnection::connect(Some(&display)) {
+ Ok(c) => c,
+ Err(e) => {
+ log::warn!("xwayland: couldn't open a connection for EWMH property updates: {e}");
+ return None;
+ }
+ };
+ let root = conn.setup().roots[screen_num].root;
+ let intern = |name: &str| -> Option<u32> { conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom) };
+ let (Some(net_active_window), Some(net_client_list), Some(net_client_list_stacking)) =
+ (intern("_NET_ACTIVE_WINDOW"), intern("_NET_CLIENT_LIST"), intern("_NET_CLIENT_LIST_STACKING"))
+ else {
+ log::warn!("xwayland: couldn't intern EWMH atoms; _NET_ACTIVE_WINDOW/_NET_CLIENT_LIST won't be maintained");
+ return None;
+ };
+ let gtk_unique_bus_name = intern("_GTK_UNIQUE_BUS_NAME");
+ let gtk_application_object_path = intern("_GTK_APPLICATION_OBJECT_PATH");
+ let gtk_window_object_path = intern("_GTK_WINDOW_OBJECT_PATH");
+ let gtk_menubar_object_path = intern("_GTK_MENUBAR_OBJECT_PATH");
+ let gtk_app_menu_object_path = intern("_GTK_APP_MENU_OBJECT_PATH");
+ let unity_object_path = intern("_UNITY_OBJECT_PATH");
+ let state = Self {
+ conn,
+ root,
+ net_active_window,
+ net_client_list,
+ net_client_list_stacking,
+ gtk_unique_bus_name,
+ gtk_application_object_path,
+ gtk_window_object_path,
+ gtk_menubar_object_path,
+ gtk_app_menu_object_path,
+ unity_object_path,
+ };
+ // `_NET_CLIENT_LIST`/`_STACKING` are properties on the X root window,
+ // which XWayland recreates fresh on every launch - but nothing
+ // guarantees a *client* reading them does so only after this
+ // compositor's own first `update_net_client_list()` call, and until
+ // that first real add/remove there is no guarantee the property even
+ // has a defined initial value. Clearing it here, before any window
+ // has ever mapped, means a freshly connected client can never read a
+ // leftover or undefined list - it always starts empty and correct.
+ state.set_client_list(&[]);
+ Some(state)
+ }
+
+ /// Reads `xid`'s global-menu D-Bus address straight off its own X11
+ /// properties - `_GTK_UNIQUE_BUS_NAME` plus whichever menu-path atom
+ /// the client actually set. `_GTK_MENUBAR_OBJECT_PATH` (a real menu
+ /// bar) wins over `_GTK_APP_MENU_OBJECT_PATH` (the single-item
+ /// fallback simpler/older clients export) if a client somehow sets
+ /// both; `_UNITY_OBJECT_PATH` is the pre-`_GTK_*` name some
+ /// still-relevant toolkits (older Qt builds with the appmenu-qt5
+ /// platform theme) use instead, tried last. No bus name means no menu
+ /// at all - the paths are meaningless without it - so this returns
+ /// `None` rather than a `GlobalMenu` with an empty `bus_name`.
+ ///
+ /// Which atom actually won is recorded as `source` - a consumer needs
+ /// it to pick the right D-Bus action-group prefix (`app`/`win` for a
+ /// real `GMenuModel`, `unity` for the older export), and
+ /// `appmenu-gtk-module` is known to set the `_GTK_*` atoms *and*
+ /// `_UNITY_OBJECT_PATH` simultaneously in some configurations - a
+ /// consumer with only the resolved path string, and no record of
+ /// which one it came from, can't tell the two cases apart even though
+ /// picking the wrong prefix means every menu item renders permanently
+ /// insensitive (a silent failure that reads exactly like a broken
+ /// app, not a wiring bug). Reported by the AGS peer session building
+ /// the consumer, from hitting exactly this live.
+ fn read_global_menu(&self, xid: u32) -> Option<srdwm_core::GlobalMenu> {
+ use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _};
+
+ let read_string = |atom: Option<u32>| -> Option<String> {
+ let atom = atom?;
+ let reply = self.conn.get_property(false, xid, atom, AtomEnum::ANY, 0, u32::MAX).ok()?.reply().ok()?;
+ if reply.value.is_empty() {
+ return None;
+ }
+ String::from_utf8(reply.value).ok().filter(|s| !s.is_empty())
+ };
+
+ let bus_name = read_string(self.gtk_unique_bus_name)?;
+ let gtk_menu_path = read_string(self.gtk_menubar_object_path).or_else(|| read_string(self.gtk_app_menu_object_path));
+ let (menu_path, source) = match gtk_menu_path {
+ Some(path) => (Some(path), srdwm_core::MenuSource::Gtk),
+ None => match read_string(self.unity_object_path) {
+ Some(path) => (Some(path), srdwm_core::MenuSource::Unity),
+ None => (None, srdwm_core::MenuSource::Gtk),
+ },
+ };
+ let app_path = read_string(self.gtk_application_object_path);
+ let window_path = read_string(self.gtk_window_object_path);
+ Some(srdwm_core::GlobalMenu { bus_name, menu_path, app_path, window_path, source })
+ }
+
+ /// `xid` is `None` when focus is on a native Wayland window (or
+ /// nothing) rather than an X11 one - `_NET_ACTIVE_WINDOW`'s value is
+ /// only meaningful for X11 clients, so this writes `0` (the documented
+ /// "no active window" sentinel) rather than leaving the last X11
+ /// window's id stale and misleading.
+ fn set_active_window(&self, xid: Option<u32>) {
+ use smithay::reexports::x11rb::connection::Connection;
+ use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, PropMode};
+ use smithay::reexports::x11rb::wrapper::ConnectionExt as _;
+ if let Err(e) = self.conn.change_property32(PropMode::REPLACE, self.root, self.net_active_window, AtomEnum::WINDOW, &[xid.unwrap_or(0)]) {
+ log::warn!("xwayland: failed to set _NET_ACTIVE_WINDOW: {e}");
+ return;
+ }
+ let _ = self.conn.flush();
+ }
+
+ fn set_client_list(&self, xids: &[u32]) {
+ use smithay::reexports::x11rb::connection::Connection;
+ use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, PropMode};
+ use smithay::reexports::x11rb::wrapper::ConnectionExt as _;
+ // Same order for both: EWMH only defines a strict order for the
+ // `_STACKING` variant (bottom-to-top), and `stacking_order` is
+ // already srdwm's one authoritative ordering of its windows - a
+ // second, differently-ordered list for plain `_NET_CLIENT_LIST`
+ // would need tracking mapping order separately for no real benefit.
+ for (atom, name) in [(self.net_client_list, "_NET_CLIENT_LIST"), (self.net_client_list_stacking, "_NET_CLIENT_LIST_STACKING")] {
+ if let Err(e) = self.conn.change_property32(PropMode::REPLACE, self.root, atom, AtomEnum::WINDOW, xids) {
+ log::warn!("xwayland: failed to set {name}: {e}");
+ return;
+ }
+ }
+ let _ = self.conn.flush();
+ }
+}
+
+impl CompState {
+ /// Call on every focus change (from `set_keyboard_focus`, the single
+ /// chokepoint every focus path already goes through). `surface` is
+ /// whatever just gained keyboard focus; resolves to an X11 window id
+ /// only if that surface's window is XWayland-backed.
+ pub(crate) fn update_net_active_window(&self, surface: Option<&smithay::reexports::wayland_server::protocol::wl_surface::WlSurface>) {
+ let Some(ewmh) = &self.ewmh else { return };
+ let id = surface.and_then(|s| self.surface_to_id.get(s)).copied();
+ let xid = id.and_then(|id| self.id_to_window.get(&id)).and_then(|w| w.x11_surface()).map(|x| x.window_id());
+ ewmh.set_active_window(xid);
+ // Global-menu properties are usually set once, shortly after a
+ // client registers on the session bus - which can race a window's
+ // own initial map, so reading them only at map time would miss a
+ // client that finished that registration a moment later. Refreshed
+ // here instead: every real focus change is a natural, already-
+ // existing hook, and a menu only actually needs to be current for
+ // whichever window is focused right now anyway. `read_global_menu`
+ // returning `None` (the common case for anything non-GTK, or a
+ // GTK app with no menu to export) correctly clears a stale value
+ // from a previous window that used to occupy this `id`.
+ if let (Some(id), Some(xid)) = (id, xid) {
+ let menu = ewmh.read_global_menu(xid);
+ if let Some(w) = self.wm.borrow_mut().window_mut(id) {
+ w.global_menu = menu;
+ }
+ }
+ }
+
+ /// Call whenever the set of mapped windows changes (X11 window map,
+ /// unmap, or destroy - see the `XwmHandler` methods below).
+ pub(crate) fn update_net_client_list(&self) {
+ let Some(ewmh) = &self.ewmh else { return };
+ let xids: Vec<u32> = self
+ .wm
+ .borrow()
+ .stacking_order()
+ .filter_map(|w| self.id_to_window.get(&w.id))
+ .filter_map(|w| w.x11_surface())
+ .map(|x| x.window_id())
+ .collect();
+ ewmh.set_client_list(&xids);
+ }
+}
+
/// Writes a small shell script named `Xwayland` to a private directory and
/// prepends that directory to this process's own `PATH` - the next
/// `Command::new("Xwayland")` (namely `XWayland::spawn`'s, which copies
@@ -195,10 +482,23 @@ impl CompState {
let _ = surface.configure(Rectangle::new((geom.x, geom.y + TITLEBAR_HEIGHT as i32).into(), (geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into()));
self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true);
- self.surface_to_id.insert(wl_surface, id);
+ self.surface_to_id.insert(wl_surface.clone(), id);
self.id_to_window.insert(id, dwindow);
self.redraw_decoration_buffer(id);
+ // `WindowManager::add_window` already made this the focused window
+ // in srdwm's own bookkeeping (it unconditionally does, for every
+ // new window), but that's purely internal state - without this, a
+ // freshly-opened XWayland app never receives a single keystroke
+ // until it's clicked, and (found investigating a downstream EWMH
+ // report) `_NET_ACTIVE_WINDOW` never updates either, since this is
+ // `set_keyboard_focus`'s only caller for X11 windows and that's the
+ // sole place `_NET_ACTIVE_WINDOW` gets written. The xdg-shell path
+ // (`new_managed_window` in state.rs) already does this; this is the
+ // equivalent X11 creation path, which never got the same fix.
+ self.set_keyboard_focus(Some(wl_surface));
self.pending.borrow_mut().push(CoreEvent::WindowCreated(id));
+ self.update_net_client_list();
+ crate::foreign_toplevel::window_created(self, id);
}
fn remove_x11_window(&mut self, xid: X11Window) {
@@ -207,8 +507,20 @@ impl CompState {
self.space.unmap_elem(&w);
}
self.decorations.remove(&id);
+ // Same reason as `state.rs`'s native `remove_window`: don't leave
+ // the context menu open against a window that's about to stop
+ // existing.
+ if self.context_menu.as_ref().is_some_and(|m| m.window == id) {
+ self.close_context_menu();
+ }
self.wm.borrow_mut().remove_window(id);
self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id));
+ crate::foreign_toplevel::window_closed(self, id);
+ // Same reason as the equivalent call in `state.rs`'s native
+ // `remove_window`: core may have already moved focus to whatever's
+ // now on top, and the Wayland/X11 side needs to be told to follow.
+ crate::input::sync_keyboard_focus(self);
+ self.update_net_client_list();
}
}
@@ -320,6 +632,70 @@ impl XwmHandler for CompState {
}
}
+ /// The same six requests found missing for native Wayland windows
+ /// (`XdgShellHandler`'s `maximize_request`/`unmaximize_request`/
+ /// `fullscreen_request`/`unfullscreen_request`/`minimize_request`,
+ /// see `protocols.rs`) exist here too, under EWMH/ICCCM naming --
+ /// `_NET_WM_STATE_MAXIMIZED_VERT`/`_HORZ`, `_NET_WM_STATE_FULLSCREEN`,
+ /// `_NET_WM_STATE_HIDDEN` toggled via a client message - and were
+ /// equally unimplemented, silently doing nothing for any XWayland
+ /// app's own window-menu maximize/minimize/fullscreen action. `move_
+ /// request`/`resize_request` right below were already implemented,
+ /// which is what made this omission easy to miss; the drag/resize
+ /// half of this class of gap already had parity, only the state-
+ /// toggle half didn't. `unminimize_request` has no native-Wayland
+ /// equivalent to mirror - xdg-shell has no client-initiated "restore
+ /// from minimized" request at all, only EWMH does.
+ fn maximize_request(&mut self, _xwm: XwmId, window: X11Surface) {
+ let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return };
+ if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+
+ fn unmaximize_request(&mut self, _xwm: XwmId, window: X11Surface) {
+ let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return };
+ if self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+
+ fn fullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) {
+ let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return };
+ if !self.wm.borrow().is_fullscreen(id) {
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+
+ fn unfullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) {
+ let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return };
+ if self.wm.borrow().is_fullscreen(id) {
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+
+ fn minimize_request(&mut self, _xwm: XwmId, window: X11Surface) {
+ let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return };
+ self.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+
+ fn unminimize_request(&mut self, _xwm: XwmId, window: X11Surface) {
+ let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return };
+ self.wm.borrow_mut().restore_window(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+
fn resize_request(&mut self, _xwm: XwmId, window: X11Surface, _button: u32, resize_edge: X11ResizeEdge) {
let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return };
let pos = self.seat.get_pointer().map(|p| p.current_location()).unwrap_or_default();