srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/config/src/lib.rs102
-rw-r--r--crates/core/src/event.rs7
-rw-r--r--crates/core/src/keysyms.rs61
-rw-r--r--crates/core/src/manager.rs268
-rw-r--r--crates/srdwm/src/main.rs8
-rw-r--r--crates/wayland/src/cursor.rs171
-rw-r--r--crates/wayland/src/input.rs67
-rw-r--r--crates/wayland/src/lib.rs1
-rw-r--r--crates/wayland/src/protocols.rs7
-rw-r--r--crates/wayland/src/state.rs7
-rw-r--r--crates/wayland/src/udev.rs44
-rw-r--r--crates/wayland/src/winit.rs2
12 files changed, 722 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(),