diff options
Diffstat (limited to 'crates/wayland/src')
| -rw-r--r-- | crates/wayland/src/context_menu.rs | 133 | ||||
| -rw-r--r-- | crates/wayland/src/cursor.rs | 473 | ||||
| -rw-r--r-- | crates/wayland/src/decoration.rs | 437 | ||||
| -rw-r--r-- | crates/wayland/src/elements.rs | 229 | ||||
| -rw-r--r-- | crates/wayland/src/foreign_toplevel.rs | 379 | ||||
| -rw-r--r-- | crates/wayland/src/gamma_control.rs | 90 | ||||
| -rw-r--r-- | crates/wayland/src/gtk_shell.rs | 116 | ||||
| -rw-r--r-- | crates/wayland/src/gtk_shell_protocol.rs | 29 | ||||
| -rw-r--r-- | crates/wayland/src/input.rs | 299 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 9 | ||||
| -rw-r--r-- | crates/wayland/src/lock.rs | 13 | ||||
| -rw-r--r-- | crates/wayland/src/output_management.rs | 559 | ||||
| -rw-r--r-- | crates/wayland/src/output_power.rs | 99 | ||||
| -rw-r--r-- | crates/wayland/src/protocols.rs | 532 | ||||
| -rw-r--r-- | crates/wayland/src/screencopy.rs | 35 | ||||
| -rw-r--r-- | crates/wayland/src/state.rs | 840 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 640 | ||||
| -rw-r--r-- | crates/wayland/src/winit.rs | 419 | ||||
| -rw-r--r-- | crates/wayland/src/workspace.rs | 251 | ||||
| -rw-r--r-- | crates/wayland/src/xkb_config.rs | 102 | ||||
| -rw-r--r-- | crates/wayland/src/xwayland.rs | 380 |
21 files changed, 5882 insertions, 182 deletions
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 ¤t { + let key = (*maximized, *minimized, *fullscreen); + if state.last_broadcast_flags.get(id) != Some(&key) { + state.last_broadcast_flags.insert(*id, key); + changed.push(*id); + } + } + // Drops entries for windows that no longer exist - `window_closed` + // already tells clients the handle itself is gone; this just stops the + // map growing forever across the life of a session. + let live: std::collections::HashSet<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) = ¤t_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(¤t_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(); |