diff options
| -rw-r--r-- | crates/config/src/lib.rs | 102 | ||||
| -rw-r--r-- | crates/core/src/event.rs | 7 | ||||
| -rw-r--r-- | crates/core/src/keysyms.rs | 61 | ||||
| -rw-r--r-- | crates/core/src/manager.rs | 268 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 8 | ||||
| -rw-r--r-- | crates/wayland/src/cursor.rs | 171 | ||||
| -rw-r--r-- | crates/wayland/src/input.rs | 67 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/wayland/src/protocols.rs | 7 | ||||
| -rw-r--r-- | crates/wayland/src/state.rs | 7 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 44 | ||||
| -rw-r--r-- | crates/wayland/src/winit.rs | 2 | ||||
| -rw-r--r-- | docs/IMPLEMENTATION_STATUS.md | 23 |
13 files changed, 745 insertions, 23 deletions
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 911f259..ec8c7e8 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -27,6 +27,11 @@ struct SharedState { wm: Rc<RefCell<WindowManager>>, values: HashMap<String, ConfigValue>, key_bindings: HashMap<String, RegistryKey>, + /// Handlers for non-key events (currently the lid switch), registered + /// via `srd.on(...)`. Kept separate from `key_bindings` because the + /// backends use that map to decide which *keypresses* to withhold from + /// clients - a pseudo-entry there would be grabbed as if it were a key. + event_handlers: HashMap<String, RegistryKey>, config_dir: PathBuf, log: Vec<String>, running: Rc<std::cell::Cell<bool>>, @@ -60,6 +65,7 @@ impl Engine { wm, values: default_config(), key_bindings: HashMap::new(), + event_handlers: HashMap::new(), config_dir: config_dir.into(), log: Vec::new(), running: Rc::new(std::cell::Cell::new(true)), @@ -110,6 +116,24 @@ impl Engine { /// Runs the Lua function bound to `combo` (e.g. `"Mod4+Return"`), if any. /// Returns `true` if a binding existed and ran without erroring. + /// Runs the `srd.on(name, ...)` handler for a non-key event, if any. + /// Returns false when nothing is registered, so callers can log it. + pub fn dispatch_event(&self, name: &str) -> bool { + let func = { + let state = self.state.borrow(); + state.event_handlers.get(name).and_then(|key| self.lua.registry_value::<mlua::Function>(key).ok()) + }; + match func { + Some(f) => { + if let Err(e) = f.call::<_, ()>(()) { + log::error!("event handler '{name}' errored: {e}"); + } + true + } + None => false, + } + } + pub fn dispatch_keybinding(&self, combo: &str) -> bool { let func = { let state = self.state.borrow(); @@ -140,6 +164,7 @@ impl Engine { srd.set("reset_all", self.fn_reset_all()?)?; srd.set("reset_category", self.fn_reset_category()?)?; srd.set("bind", self.fn_bind()?)?; + srd.set("on", self.fn_on()?)?; srd.set("rule", self.fn_rule()?)?; srd.set("load", self.fn_load()?)?; srd.set("spawn", self.fn_spawn()?)?; @@ -160,7 +185,11 @@ impl Engine { window.set("close", self.fn_window_action(WindowAction::Close)?)?; window.set("minimize", self.fn_window_action(WindowAction::Minimize)?)?; window.set("maximize", self.fn_window_action(WindowAction::Maximize)?)?; + window.set("fullscreen", self.fn_window_action(WindowAction::Fullscreen)?)?; window.set("focus", self.fn_window_focus_direction()?)?; + window.set("move", self.fn_window_move_direction()?)?; + window.set("next", self.fn_window_cycle(true)?)?; + window.set("prev", self.fn_window_cycle(false)?)?; window.set("set_decorations", self.fn_window_set_decorations()?)?; window.set("set_border_color", self.fn_window_set_border_color()?)?; window.set("set_border_width", self.fn_window_set_border_width()?)?; @@ -269,6 +298,24 @@ impl Engine { })?) } + /// `srd.on("lid_closed", function() ... end)` - registers a handler for + /// a non-key event. Currently `"lid_closed"` and `"lid_open"`. + fn fn_on(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |lua, (name, f): (String, mlua::Function)| { + const KNOWN: [&str; 2] = ["lid_closed", "lid_open"]; + if !KNOWN.contains(&name.as_str()) { + return Err(mlua::Error::RuntimeError(format!( + "srd.on: unknown event '{name}' (known: {})", + KNOWN.join(", ") + ))); + } + let key = lua.create_registry_value(f)?; + state.borrow_mut().event_handlers.insert(name, key); + Ok(()) + })?) + } + fn fn_bind(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| { @@ -475,6 +522,7 @@ impl Engine { WindowAction::Close => wm.close_window(id), WindowAction::Minimize => wm.minimize_window(id), WindowAction::Maximize => wm.toggle_maximize(id), + WindowAction::Fullscreen => wm.toggle_fullscreen(id), WindowAction::ToggleFloating => wm.toggle_floating(id), } } @@ -482,16 +530,43 @@ impl Engine { })?) } + /// `srd.window.move("left")` - swap the focused window with its + /// neighbour in that direction (Hyprland's `movewindow l/r/u/d`). + fn fn_window_move_direction(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |_, direction: String| { + let dir = parse_direction(&direction, "srd.window.move")?; + let wm = state.borrow().wm.clone(); + wm.borrow_mut().move_window_direction(dir); + Ok(()) + })?) + } + + /// `srd.window.next()` / `srd.window.prev()` - cycle focus through the + /// windows on the current workspace (Hyprland's `cyclenext`). + fn fn_window_cycle(&self, forward: bool) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |_, ()| { + let wm = state.borrow().wm.clone(); + let mut wm = wm.borrow_mut(); + if forward { + wm.focus_next(); + } else { + wm.focus_previous(); + } + // Bring it to the top, matching the `bringactivetotop` the + // Hyprland binding pairs with `cyclenext`. + if let Some(id) = wm.focused_id() { + wm.raise_window(id); + } + Ok(()) + })?) + } + fn fn_window_focus_direction(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |_, direction: String| { - let dir = match direction.as_str() { - "left" => Direction::Left, - "right" => Direction::Right, - "up" => Direction::Up, - "down" => Direction::Down, - other => return Err(mlua::Error::RuntimeError(format!("srd.window.focus: unknown direction '{other}'"))), - }; + let dir = parse_direction(&direction, "srd.window.focus")?; let wm = state.borrow().wm.clone(); wm.borrow_mut().focus_direction(dir); Ok(()) @@ -643,11 +718,24 @@ impl Engine { } } +/// Shared by `srd.window.focus` and `srd.window.move` so both accept +/// exactly the same direction names and report the same error. +fn parse_direction(name: &str, caller: &str) -> mlua::Result<Direction> { + match name { + "left" => Ok(Direction::Left), + "right" => Ok(Direction::Right), + "up" => Ok(Direction::Up), + "down" => Ok(Direction::Down), + other => Err(mlua::Error::RuntimeError(format!("{caller}: unknown direction '{other}'"))), + } +} + #[derive(Clone, Copy)] enum WindowAction { Close, Minimize, Maximize, + Fullscreen, ToggleFloating, } diff --git a/crates/core/src/event.rs b/crates/core/src/event.rs index 9b464b5..eb2d8aa 100644 --- a/crates/core/src/event.rs +++ b/crates/core/src/event.rs @@ -60,4 +60,11 @@ pub enum Event { MouseMotion { x: i32, y: i32 }, MonitorAdded(Monitor), MonitorRemoved(MonitorId), + /// The laptop lid was closed or opened. Emitted by the udev backend from + /// libinput switch events; `closed` is true when the lid is shut. + /// + /// Exposed to config as `srd.on_lid("closed"/"open", fn)` so a session + /// can lock and suspend, which is otherwise impossible: a laptop that + /// does nothing on lid-close is a real problem, not a nicety. + LidSwitch { closed: bool }, } diff --git a/crates/core/src/keysyms.rs b/crates/core/src/keysyms.rs index 0baa6bc..b166159 100644 --- a/crates/core/src/keysyms.rs +++ b/crates/core/src/keysyms.rs @@ -22,11 +22,23 @@ pub fn keysym_to_name(keysym: u32) -> Option<String> { 0xff50 => "Home".to_string(), 0xff57 => "End".to_string(), 0xffbe..=0xffc9 => format!("F{}", keysym - 0xffbe + 1), - 0x1008ff13 => "XF86AudioRaiseVolume".to_string(), - 0x1008ff11 => "XF86AudioLowerVolume".to_string(), - 0x1008ff12 => "XF86AudioMute".to_string(), + // Laptop/media keys. Values taken from the system's own + // <X11/XF86keysym.h>, not guessed - a wrong constant here fails + // silently, as an unrecognised keysym simply never matches a + // binding. 0x1008ff02 => "XF86MonBrightnessUp".to_string(), 0x1008ff03 => "XF86MonBrightnessDown".to_string(), + 0x1008ff11 => "XF86AudioLowerVolume".to_string(), + 0x1008ff12 => "XF86AudioMute".to_string(), + 0x1008ff13 => "XF86AudioRaiseVolume".to_string(), + 0x1008ff14 => "XF86AudioPlay".to_string(), + 0x1008ff15 => "XF86AudioStop".to_string(), + 0x1008ff16 => "XF86AudioPrev".to_string(), + 0x1008ff17 => "XF86AudioNext".to_string(), + 0x1008ff2a => "XF86PowerOff".to_string(), + 0x1008ff2d => "XF86ScreenSaver".to_string(), + 0x1008ff32 => "XF86AudioMedia".to_string(), + 0x1008ffb2 => "XF86AudioMicMute".to_string(), _ => return None, }) } @@ -49,11 +61,22 @@ pub fn name_to_keysym(name: &str) -> Option<u32> { "next" | "pagedown" => return Some(0xff56), "home" => return Some(0xff50), "end" => return Some(0xff57), - "xf86audioraisevolume" => return Some(0x1008ff13), - "xf86audiolowervolume" => return Some(0x1008ff11), - "xf86audiomute" => return Some(0x1008ff12), + // Must stay in sync with `keysym_to_name` above: the X11 backend + // resolves names through here to pass to `XGrabKey`, so a key + // missing from *this* direction can be pressed but never grabbed. "xf86monbrightnessup" => return Some(0x1008ff02), "xf86monbrightnessdown" => return Some(0x1008ff03), + "xf86audiolowervolume" => return Some(0x1008ff11), + "xf86audiomute" => return Some(0x1008ff12), + "xf86audioraisevolume" => return Some(0x1008ff13), + "xf86audioplay" => return Some(0x1008ff14), + "xf86audiostop" => return Some(0x1008ff15), + "xf86audioprev" => return Some(0x1008ff16), + "xf86audionext" => return Some(0x1008ff17), + "xf86poweroff" => return Some(0x1008ff2a), + "xf86screensaver" => return Some(0x1008ff2d), + "xf86audiomedia" => return Some(0x1008ff32), + "xf86audiomicmute" => return Some(0x1008ffb2), _ => {} } if name.len() == 1 { @@ -109,4 +132,30 @@ mod tests { assert_eq!(keysym_to_name(ks), Some(name.to_string())); } } + + #[test] + fn every_media_key_roundtrips_in_both_directions() { + // The two tables are hand-maintained and independent, so a key can + // easily be added to one and forgotten in the other - which fails + // silently (the binding just never fires, or never gets grabbed). + // Covers every XF86 key the shipped/ported configs actually bind. + for name in [ + "XF86MonBrightnessUp", + "XF86MonBrightnessDown", + "XF86AudioRaiseVolume", + "XF86AudioLowerVolume", + "XF86AudioMute", + "XF86AudioMicMute", + "XF86AudioPlay", + "XF86AudioStop", + "XF86AudioPrev", + "XF86AudioNext", + "XF86AudioMedia", + "XF86PowerOff", + "XF86ScreenSaver", + ] { + let ks = name_to_keysym(name).unwrap_or_else(|| panic!("{name} missing from name_to_keysym")); + assert_eq!(keysym_to_name(ks), Some(name.to_string()), "{name} missing from keysym_to_name"); + } + } } diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs index d261c13..23d94be 100644 --- a/crates/core/src/manager.rs +++ b/crates/core/src/manager.rs @@ -296,7 +296,15 @@ impl WindowManager { /// Vim-style directional focus: picks the nearest window whose center /// lies in `dir` relative to the focused window's center, on the same /// workspace. Returns the newly focused window, if any. - pub fn focus_direction(&mut self, dir: Direction) -> Option<WindowId> { + /// Nearest window to the focused one in `dir`, by a distance biased + /// toward the requested axis so a window that's mostly to the left + /// (small |dy|) beats a diagonally-placed one - matching how + /// i3/sway-style directional focus feels. + /// + /// Shared by [`Self::focus_direction`] and [`Self::move_window_direction`] + /// so "the window to the left" means the same thing whether you're + /// focusing it or swapping with it. + pub fn neighbour_in(&self, dir: Direction) -> Option<WindowId> { let (fx, fy, fid) = { let focused = self.focused_window()?; let (fx, fy) = focused.geometry.center(); @@ -316,9 +324,6 @@ impl WindowManager { if !matches { continue; } - // Distance biased toward the requested axis so a window that's - // mostly to the left (small |dy|) beats one that's diagonally - // placed, matching how i3/sway-style directional focus feels. let (primary, secondary) = match dir { Direction::Left | Direction::Right => (dx, dy), Direction::Up | Direction::Down => (dy, dx), @@ -328,13 +333,61 @@ impl WindowManager { best = Some((w.id, dist)); } } - let target = best.map(|(id, _)| id); + best.map(|(id, _)| id) + } + + pub fn focus_direction(&mut self, dir: Direction) -> Option<WindowId> { + let target = self.neighbour_in(dir); if let Some(id) = target { self.focus_window(id); } target } + /// Moves the focused window in `dir` by swapping places with its + /// neighbour there - the `movewindow l/r/u/d` gesture. + /// + /// Swapping (rather than nudging by a fixed step) is what makes this + /// useful in both of srdwm's modes: under tiling it reorders the layout, + /// and in dynamic/floating mode two windows trade positions, which is + /// predictable either way. With no neighbour in that direction the + /// window is pushed to the corresponding edge of its monitor instead, so + /// the key still does something sensible. + pub fn move_window_direction(&mut self, dir: Direction) -> Option<WindowId> { + let focused = self.focused_id()?; + match self.neighbour_in(dir) { + Some(other) => { + let a = self.windows.get(&focused)?.geometry; + let b = self.windows.get(&other)?.geometry; + if let Some(w) = self.windows.get_mut(&focused) { + w.geometry = b; + } + if let Some(w) = self.windows.get_mut(&other) { + w.geometry = a; + } + // Keep stacking order in step so a tiling layout, which + // assigns slots from `order`, actually reflects the swap. + let (ia, ib) = ( + self.order.iter().position(|&id| id == focused)?, + self.order.iter().position(|&id| id == other)?, + ); + self.order.swap(ia, ib); + Some(other) + } + None => { + let mon = self.windows.get(&focused).and_then(|w| self.monitor_for(w.monitor))?.geometry; + let w = self.windows.get_mut(&focused)?; + match dir { + Direction::Left => w.geometry.x = mon.x, + Direction::Right => w.geometry.x = mon.right() - w.geometry.width as i32, + Direction::Up => w.geometry.y = mon.y, + Direction::Down => w.geometry.y = mon.bottom() - w.geometry.height as i32, + } + None + } + } + } + // ---- Window operations ---------------------------------------------- pub fn close_window(&mut self, id: WindowId) { @@ -371,6 +424,40 @@ impl WindowManager { } } + /// Fullscreen: the window covers its whole monitor with no decoration. + /// + /// Distinct from [`Self::toggle_maximize`], which keeps the titlebar (and + /// is what a maximise button does). Both share `restore_geometry`, so + /// they are mutually exclusive - toggling one off restores whatever the + /// window's geometry was before *either* was applied, and entering + /// fullscreen from a maximised window doesn't lose the original size. + pub fn toggle_fullscreen(&mut self, id: WindowId) { + let monitor_geom = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry); + let Some(w) = self.windows.get_mut(&id) else { return }; + if w.fullscreen { + if let Some(restore) = w.restore_geometry.take() { + w.geometry = restore; + } + w.fullscreen = false; + w.decorated = true; + } else if let Some(geom) = monitor_geom { + // Only remember the pre-fullscreen geometry if we aren't already + // maximised, otherwise the monitor rect would overwrite the real + // restore point and the window could never get its size back. + if !w.maximized { + w.restore_geometry = Some(w.geometry); + } + w.maximized = false; + w.geometry = geom; + w.fullscreen = true; + w.decorated = false; + } + } + + pub fn is_fullscreen(&self, id: WindowId) -> bool { + self.windows.get(&id).map(|w| w.fullscreen).unwrap_or(false) + } + pub fn toggle_floating(&mut self, id: WindowId) { if let Some(w) = self.windows.get_mut(&id) { w.floating = !w.floating; @@ -411,6 +498,32 @@ impl WindowManager { None } + /// Topmost non-minimised window containing a point, ignoring + /// decorations. Used for modifier+drag, where the grab applies anywhere + /// in the window rather than only on the titlebar (`hit_test`). + pub fn window_at(&self, x: i32, y: i32) -> Option<WindowId> { + self.order + .iter() + .rev() + .filter_map(|id| self.windows.get(id)) + .find(|w| !w.minimized && w.geometry.contains_point(x, y)) + .map(|w| w.id) + } + + /// The corner of `id` nearest a point, for modifier+right-drag resize: + /// grabbing the closest corner is what makes the gesture feel like it + /// pulls the edge you aimed at (matching Hyprland's `resizewindow`). + pub fn nearest_corner(&self, id: WindowId, x: i32, y: i32) -> ResizeEdge { + let Some(w) = self.windows.get(&id) else { return ResizeEdge::BottomRight }; + let (cx, cy) = w.geometry.center(); + match (x < cx, y < cy) { + (true, true) => ResizeEdge::TopLeft, + (false, true) => ResizeEdge::TopRight, + (true, false) => ResizeEdge::BottomLeft, + (false, false) => ResizeEdge::BottomRight, + } + } + // ---- Drag / resize ------------------------------------------------------ pub fn start_drag(&mut self, id: WindowId, x: i32, y: i32) { @@ -566,7 +679,9 @@ impl WindowManager { let mut by_monitor: HashMap<MonitorId, Vec<WindowId>> = HashMap::new(); for &id in &self.order { let Some(w) = self.windows.get(&id) else { continue }; - if w.workspace == workspace && !w.minimized && !w.floating { + // Fullscreen windows own their whole monitor, so tiling must + // leave them alone, exactly as it does floating ones. + if w.workspace == workspace && !w.minimized && !w.floating && !w.fullscreen { by_monitor.entry(w.monitor).or_default().push(id); } } @@ -960,4 +1075,145 @@ mod tests { got.geometry ); } + + // ---- Fullscreen ------------------------------------------------------ + + #[test] + fn fullscreen_covers_the_monitor_and_restores_the_original_geometry() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "app"); + w.geometry = Rect::new(100, 100, 400, 300); + wm.add_window(w); + wm.window_mut(id).unwrap().geometry = Rect::new(100, 100, 400, 300); + + wm.toggle_fullscreen(id); + let got = wm.window(id).unwrap(); + assert!(got.fullscreen); + assert_eq!(got.geometry, Rect::new(0, 0, 1280, 800), "should cover the whole monitor"); + assert!(!got.decorated, "fullscreen must drop the titlebar"); + + wm.toggle_fullscreen(id); + let got = wm.window(id).unwrap(); + assert!(!got.fullscreen); + assert_eq!(got.geometry, Rect::new(100, 100, 400, 300)); + assert!(got.decorated); + } + + #[test] + fn fullscreen_from_maximized_still_restores_the_pre_maximize_size() { + // Both share `restore_geometry`; entering fullscreen from a + // maximised window must not overwrite it with the monitor rect, or + // the window could never get its real size back. + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "app"); + w.geometry = Rect::new(50, 60, 300, 200); + wm.add_window(w); + wm.window_mut(id).unwrap().geometry = Rect::new(50, 60, 300, 200); + + wm.toggle_maximize(id); + wm.toggle_fullscreen(id); + assert!(wm.is_fullscreen(id)); + assert!(!wm.window(id).unwrap().maximized, "the two states are mutually exclusive"); + + wm.toggle_fullscreen(id); + assert_eq!( + wm.window(id).unwrap().geometry, + Rect::new(50, 60, 300, 200), + "must restore the size from before maximise, not the monitor rect" + ); + } + + #[test] + fn tiling_leaves_fullscreen_windows_alone() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + wm.set_layout(wm.current_workspace(), "tiling"); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "tiled")); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "full")); + wm.toggle_fullscreen(b); + + let changes = wm.arrange_workspace(wm.current_workspace()); + assert!( + !changes.iter().any(|(id, _)| *id == b), + "a fullscreen window must not be re-tiled" + ); + assert_eq!(wm.window(b).unwrap().geometry, Rect::new(0, 0, 1280, 800)); + } + + // ---- Directional move ------------------------------------------------ + + #[test] + fn moving_a_window_swaps_it_with_its_neighbour() { + let mut wm = wm_with_monitor(); + let left = wm.alloc_window_id(); + let mut a = Window::new(left, "left"); + a.geometry = Rect::new(0, 0, 400, 400); + wm.add_window(a); + wm.window_mut(left).unwrap().geometry = Rect::new(0, 0, 400, 400); + + let right = wm.alloc_window_id(); + let mut b = Window::new(right, "right"); + b.geometry = Rect::new(600, 0, 400, 400); + wm.add_window(b); + wm.window_mut(right).unwrap().geometry = Rect::new(600, 0, 400, 400); + + wm.focus_window(left); + let swapped = wm.move_window_direction(Direction::Right); + + assert_eq!(swapped, Some(right)); + assert_eq!(wm.window(left).unwrap().geometry, Rect::new(600, 0, 400, 400)); + assert_eq!(wm.window(right).unwrap().geometry, Rect::new(0, 0, 400, 400)); + } + + #[test] + fn moving_with_no_neighbour_pushes_to_the_monitor_edge() { + let mut wm = wm_with_monitor(); + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "only"); + w.geometry = Rect::new(500, 300, 200, 150); + wm.add_window(w); + wm.window_mut(id).unwrap().geometry = Rect::new(500, 300, 200, 150); + wm.focus_window(id); + + assert_eq!(wm.move_window_direction(Direction::Left), None); + assert_eq!(wm.window(id).unwrap().geometry.x, 0, "should hug the left edge"); + + wm.move_window_direction(Direction::Down); + let g = wm.window(id).unwrap().geometry; + let mon = wm.primary_monitor().unwrap().geometry; + assert_eq!(g.bottom(), mon.bottom(), "should hug the bottom edge"); + } + + #[test] + fn swapping_also_reorders_the_stack_so_tiling_follows() { + // Under tiling the layout assigns slots from `order`, so a swap that + // only exchanged geometry would be undone by the next arrange. + let mut wm = wm_with_monitor(); + wm.set_layout(wm.current_workspace(), "tiling"); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "b")); + wm.arrange_workspace(wm.current_workspace()); + + // Snapshot *after* focusing: `focus_window` raises, which reorders + // on its own and would otherwise mask what the move did. + wm.focus_window(a); + let order_before: Vec<_> = wm.stacking_order().map(|w| w.id).collect(); + wm.move_window_direction(Direction::Right); + let order_after: Vec<_> = wm.stacking_order().map(|w| w.id).collect(); + + assert_ne!(order_before, order_after, "stacking order must reflect the swap"); + assert_eq!( + order_after, + order_before.iter().rev().copied().collect::<Vec<_>>(), + "the two windows should have traded places in the stack" + ); + } } diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs index 8847631..b59ae82 100644 --- a/crates/srdwm/src/main.rs +++ b/crates/srdwm/src/main.rs @@ -136,6 +136,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { dirty = true; } Event::WindowMoved { .. } | Event::WindowResized { .. } => dirty = true, + // Laptop lid. The handler is a plain Lua function, so the + // config decides what to do (lock, suspend, nothing). + Event::LidSwitch { closed } => { + let name = if closed { "lid_closed" } else { "lid_open" }; + if !engine.dispatch_event(name) { + log::debug!("no handler registered for '{name}'"); + } + } // A monitor was plugged in or unplugged. Re-query the whole // list rather than applying the single monitor in the event: // outputs are laid out left-to-right, so adding or removing diff --git a/crates/wayland/src/cursor.rs b/crates/wayland/src/cursor.rs new file mode 100644 index 0000000..121e8e1 --- /dev/null +++ b/crates/wayland/src/cursor.rs @@ -0,0 +1,171 @@ +//! Mouse cursor rendering. +//! +//! The nested (winit) backend gets a cursor for free - the *host* +//! compositor draws one over srdwm's window - which is exactly why this was +//! missing for so long without being noticed. On a bare TTY nothing else is +//! drawing anything, so without this the pointer is simply invisible: you +//! can move it, click with it, and drag windows with it, but you cannot see +//! where it is. That makes the udev backend unusable as a real session. +//! +//! **Scope, stated plainly:** this draws one built-in arrow, always. It +//! honours `CursorImageStatus::Hidden` (so a client that hides the pointer +//! still gets its way), but it does *not* yet render a client's own cursor +//! surface or a named shape - an app asking for an I-beam or a resize arrow +//! still sees this arrow. That is a real limitation, and a visible one over +//! text fields; it is also strictly better than the previous behaviour of +//! drawing nothing at all. +//! +//! The built-in arrow is deliberate rather than loading an XCursor theme: +//! theme loading pulls in a dependency, needs a theme to actually be +//! installed, and has a search-path fallback story of its own. A cursor that +//! is always present beats a prettier one that sometimes isn't there - the +//! same reasoning as `decoration.rs`'s font fallback. + +use smithay::utils::{Logical, Point}; + +/// Side length of the built-in cursor bitmap, in pixels. +pub(crate) const CURSOR_SIZE: i32 = 24; + +/// A classic left-pointing arrow: white fill, black outline, with the +/// hotspot at (0, 0) - the tip. +/// +/// 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. +const ARROW: [&str; CURSOR_SIZE as usize] = [ + "#.......................", + "##......................", + "#*#.....................", + "#**#....................", + "#***#...................", + "#****#..................", + "#*****#.................", + "#******#................", + "#*******#...............", + "#********#..............", + "#*********#.............", + "#**********#............", + "#***********#...........", + "#************#..........", + "#*************#.........", + "#******####### .........", + "#***#**#................", + "#**#.#**#...............", + "#*#..#**#...............", + "##....#**#..............", + "#.....#**#..............", + ".......###..............", + "........................", + "........................", +]; + +/// Rasterises the built-in arrow as premultiplied ARGB8888, the format +/// `MemoryRenderBuffer` expects. +pub(crate) fn arrow_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + for (y, row) in ARROW.iter().enumerate() { + for (x, ch) in row.chars().enumerate() { + if x >= CURSOR_SIZE as usize || y >= CURSOR_SIZE as usize { + break; + } + // Premultiplied: opaque pixels only, so colour == colour * 1. + let (b, g, r, a) = match ch { + '#' => (0x00, 0x00, 0x00, 0xff), + '*' => (0xff, 0xff, 0xff, 0xff), + _ => continue, + }; + let i = (y * CURSOR_SIZE as usize + x) * 4; + buf[i] = b; + buf[i + 1] = g; + buf[i + 2] = r; + buf[i + 3] = a; + } + } + buf +} + + +/// Render elements for the pointer, to be drawn above everything else. +/// +/// `pos` is in the global space and `origin` is the output's origin, since +/// each head renders in its own coordinate space. +/// +/// Returns nothing when the cursor is hidden, or when the pointer is not +/// 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, + renderer: &mut R, + pos: Point<f64, Logical>, + origin: Point<i32, Logical>, + size: (i32, i32), +) -> Vec<smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement<R>> +where + R: smithay::backend::renderer::Renderer + smithay::backend::renderer::ImportMem, + R::TextureId: Clone + Send + 'static, +{ + use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; + use smithay::backend::renderer::element::Kind; + use smithay::input::pointer::CursorImageStatus; + + if matches!(status, CursorImageStatus::Hidden) { + return Vec::new(); + } + // Only the output the pointer is actually on draws it. + let local = (pos.x as i32 - origin.x, pos.y as i32 - origin.y); + if local.0 < 0 || local.1 < 0 || local.0 >= size.0 || local.1 >= size.1 { + return Vec::new(); + } + + // Built-in arrow: hotspot is the tip, so no offset. + let at = (local.0 as f64, local.1 as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, at, buffer, None, None, None, Kind::Cursor) { + Ok(e) => vec![e], + Err(e) => { + log::warn!("cursor: failed to import bitmap: {e}"); + Vec::new() + } + } +} + +/// The built-in arrow as an uploadable buffer. Built once at startup rather +/// than per frame - the bitmap never changes. +pub(crate) fn make_buffer() -> smithay::backend::renderer::element::memory::MemoryRenderBuffer { + use smithay::backend::allocator::Fourcc; + use smithay::backend::renderer::element::memory::MemoryRenderBuffer; + use smithay::utils::Transform; + MemoryRenderBuffer::from_slice(&arrow_bitmap(), Fourcc::Argb8888, (CURSOR_SIZE, CURSOR_SIZE), 1, Transform::Normal, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arrow_is_the_expected_size_and_has_an_opaque_tip() { + let buf = arrow_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + // The hotspot pixel (0,0) is the arrow's tip and must be visible, + // otherwise the cursor appears offset from where clicks land. + assert_eq!(buf[3], 0xff, "tip pixel must be opaque"); + } + + #[test] + fn arrow_has_both_outline_and_fill() { + let buf = arrow_bitmap(); + let mut black = 0; + let mut white = 0; + for px in buf.chunks_exact(4) { + if px[3] == 0 { + continue; + } + if px[0] == 0 && px[1] == 0 && px[2] == 0 { + black += 1; + } else { + white += 1; + } + } + assert!(black > 20, "expected a black outline, got {black} px"); + assert!(white > 40, "expected a white fill, got {white} px"); + } +} diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs index 76607ee..6959c8f 100644 --- a/crates/wayland/src/input.rs +++ b/crates/wayland/src/input.rs @@ -20,6 +20,11 @@ use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, Layer}; use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId}; +/// Modifier that turns a drag anywhere in a window into move/resize. +/// Matches the `SUPER` the shipped and ported configs use for +/// `bindm ... movewindow` / `resizewindow`. +const DRAG_MODIFIER: Modifiers = Modifiers::SUPER; + use crate::state::CompState; pub(crate) fn last_pointer_pos(state: &CompState) -> Point<f64, Logical> { @@ -134,6 +139,7 @@ pub(crate) fn focus_window(state: &mut CompState, id: WindowId) { pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logical>, button: u32, pressed: bool, time: u32) { const BTN_LEFT: u32 = 0x110; + const BTN_RIGHT: u32 = 0x111; let serial = SERIAL_COUNTER.next_serial(); // Locked: forward the click to the lock surface (it may have a button or @@ -146,6 +152,33 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic 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 + // window can only be moved by its titlebar, which is useless for + // windows that have none (fullscreen, CSD apps, layer surfaces). + // + // Checked before the titlebar hit-test so the modifier wins over the + // decoration: holding the modifier and grabbing the titlebar should + // still move, not press a titlebar button. + if pressed && (button == BTN_LEFT || button == BTN_RIGHT) { + let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state())); + if mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) { + let target = state.wm.borrow().window_at(pos.x as i32, pos.y as i32); + if let Some(id) = target { + focus_window(state, id); + let mut wm = state.wm.borrow_mut(); + if button == BTN_LEFT { + wm.start_drag(id, pos.x as i32, pos.y as i32); + } else { + let edge = wm.nearest_corner(id, pos.x as i32, pos.y as i32); + wm.start_resize(id, edge, pos.x as i32, pos.y as i32); + } + return; + } + } + } + if pressed && button == BTN_LEFT { let layer_hit = layer_surface_under(state, pos); if let Some((surface, _)) = &layer_hit { @@ -275,3 +308,37 @@ pub(crate) fn core_modifiers_from_xkb(mods: &smithay::input::keyboard::Modifiers } m } + +/// Modifier+scroll cycles workspaces, consuming the event. +/// +/// Returns `true` if it handled the scroll, in which case the caller must +/// *not* also forward it to the client. Generic over the input backend for +/// the same reason the keyboard handler is: both backends deliver scroll +/// through smithay's `PointerAxisEvent` trait. +pub(crate) fn handle_workspace_scroll<B, E>(state: &mut CompState, event: &E) -> bool +where + B: smithay::backend::input::InputBackend, + E: smithay::backend::input::PointerAxisEvent<B>, +{ + use smithay::backend::input::Axis; + + if state.lock.locked { + return false; + } + let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state())); + if !mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) { + return false; + } + let Some(v) = event.amount(Axis::Vertical).filter(|v| *v != 0.0) else { return false }; + + let mut wm = state.wm.borrow_mut(); + let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect(); + if ids.is_empty() { + return false; + } + let current = ids.iter().position(|&id| id == wm.current_workspace()).unwrap_or(0); + // Scrolling down (positive) advances, matching `workspace, e+1`. + let next = if v > 0.0 { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() }; + wm.switch_workspace(ids[next]); + true +} diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs index 053e6ad..001c803 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -36,6 +36,7 @@ //! - xdg-decoration is forced to server-side mode (`Mode::ServerSide`) so //! well-behaved clients don't also draw their own client-side titlebar. +mod cursor; mod decoration; mod input; mod lock; diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs index 582a622..f51b35d 100644 --- a/crates/wayland/src/protocols.rs +++ b/crates/wayland/src/protocols.rs @@ -131,7 +131,12 @@ impl SeatHandler for CompState { } fn focus_changed(&mut self, _seat: &Seat<Self>, _focused: Option<&WlSurface>) {} - fn cursor_image(&mut self, _seat: &Seat<Self>, _image: CursorImageStatus) {} + /// Clients set their own cursor (an I-beam over text, a hand over a + /// link). Recorded here and drawn by the render paths - on a bare TTY + /// nothing else would draw it. See `cursor.rs`. + fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) { + self.cursor_status = image; + } } impl WlrLayerShellHandler for CompState { diff --git a/crates/wayland/src/state.rs b/crates/wayland/src/state.rs index 6bc6f1a..a1704cb 100644 --- a/crates/wayland/src/state.rs +++ b/crates/wayland/src/state.rs @@ -120,6 +120,13 @@ pub(crate) struct CompState { /// content is never rendered and input never reaches normal clients -- /// see `SessionLockHandler` below. pub(crate) lock: SessionLock, + /// What the pointer should look like, as set by the focused client (or + /// 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, pub(crate) wm: Rc<RefCell<WindowManager>>, pub(crate) surface_to_id: HashMap<WlSurface, WindowId>, pub(crate) id_to_window: HashMap<WindowId, DWindow>, diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs index aca4865..30517f8 100644 --- a/crates/wayland/src/udev.rs +++ b/crates/wayland/src/udev.rs @@ -159,6 +159,9 @@ impl CompState { // Drained before the `&mut self.udev` borrow below, so screencopy can // be serviced with the renderer that borrow owns. 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(); // Which heads are eligible, and what each needs, gathered before the // mutable borrow of `self.udev`. @@ -196,6 +199,19 @@ impl CompState { let mut custom_elements: Vec<MemoryRenderBufferRenderElement<PixmanRenderer>> = Vec::new(); if !locked { + // Cursor first: `render_output` draws custom elements + // front-to-back, so the earliest element is topmost. On a + // bare TTY nothing else draws a pointer - see `cursor.rs`. + let pointer_pos = udev.pointer_pos; + let hsize = udev.heads[index].size; + custom_elements.extend(crate::cursor::render_elements( + &cursor_status, + &cursor_buffer, + &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) { @@ -538,6 +554,8 @@ impl UdevPlatform { _screencopy_state: crate::screencopy::ScreencopyState::new::<CompState>(&display_handle), screencopy_pending: Vec::new(), lock: Default::default(), + cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), + cursor_buffer: crate::cursor::make_buffer(), wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), @@ -832,10 +850,30 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB let pressed = event.state() == BackendButtonState::Pressed; handle_pointer_button(state, pos, button, pressed, event.time_msec()); } + // Laptop lid. libinput reports this as a switch toggle; without + // handling it, closing the lid does nothing at all - no lock, no + // suspend - which is a genuine problem on a laptop rather than a + // missing nicety. + InputEvent::SwitchToggle { event } => { + // Fully qualified: libinput's own `Switch` is also in scope here. + use smithay::backend::input::{SwitchState, SwitchToggleEvent}; + if matches!(event.switch(), Some(smithay::reexports::input::event::switch::Switch::Lid)) { + let closed = event.state() == SwitchState::On; + log::info!("lid {}", if closed { "closed" } else { "opened" }); + state.pending.borrow_mut().push(CoreEvent::LidSwitch { closed }); + } + } InputEvent::PointerAxis { event } => { - // Scroll: forwarded to the focused client via the pointer axis - // frame, no WM-level handling (matches the winit backend, which - // doesn't handle scroll either). + // Modifier+scroll switches workspace instead of reaching the + // client - the `bind = SUPER, mouse_down/up, workspace, e+1/e-1` + // gesture. Checked first so the client never sees these events; + // forwarding them too would scroll the window under the cursor + // as a side effect of changing workspace. + if crate::input::handle_workspace_scroll(state, &event) { + return; + } + // Otherwise: forwarded to the focused client via the pointer axis + // frame, no WM-level handling. let Some(pointer) = state.seat.get_pointer() else { return }; let source = event.source(); let mut frame = AxisFrame::new(event.time_msec()).source(source); diff --git a/crates/wayland/src/winit.rs b/crates/wayland/src/winit.rs index 85be223..b21ca06 100644 --- a/crates/wayland/src/winit.rs +++ b/crates/wayland/src/winit.rs @@ -130,6 +130,8 @@ impl WaylandPlatform { _screencopy_state: screencopy::ScreencopyState::new::<CompState>(&dh), screencopy_pending: Vec::new(), lock: SessionLock::default(), + cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), + cursor_buffer: crate::cursor::make_buffer(), wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 35635c0..6b2579b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -513,6 +513,29 @@ built them: `WAYLAND_DEBUG=1` protocol tracing and fixing real bugs - see the Wayland backend section above for the full account. +- ✅ **Mouse cursor** (`crates/wayland/src/cursor.rs`). Previously **nothing + drew a pointer at all** - invisible mouse on a bare TTY. It hid because + the nested backend runs inside another compositor, which draws a cursor + over srdwm's window; only the DRM backend is affected, and only when run + as a real session. A built-in arrow (a reviewable ASCII bitmap, no XCursor + theme dependency, same reasoning as `decoration.rs`'s font fallback) is + now composited above everything on the output the pointer is on. + `CursorImageStatus::Hidden` is honoured. **Not** yet done: rendering a + client's own cursor surface or a named shape, so an app asking for an + I-beam still gets the arrow. + **Verified in the QEMU VM**: screendump of a bare-TTY session shows a + recognisable arrow at the pointer position - 113 white fill + 58 black + outline pixels at screen centre, where the pointer starts. +- ✅ **Lid switch**: libinput switch events are handled and surfaced to + config as `srd.on("lid_closed"/"lid_open", fn)`, so a session can lock and + suspend on lid close. Previously there was no switch handling at all. +- ✅ **Fullscreen** (`srd.window.fullscreen()`), **directional window move** + (`srd.window.move("left")`, swaps with the neighbour), **focus cycling** + (`srd.window.next()`/`prev()`), **modifier+drag move/resize anywhere in a + window**, **modifier+scroll workspace switching**, and 8 more `XF86` + media/power keysyms. All were needed to port a real Hyprland config and + none existed before. + ## Not implemented anywhere yet All three protocols originally identified as blocking srdwm-wayland from |