From 3d3057ae384ef7389284af8988410889e99c6bb9 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Wed, 29 May 2024 14:40:00 +0200 Subject: Config at ~/.config/srd; cursor shapes, key repeat, pin, mouse defaults Config path drops a level: ~/.config/srd, not ~/.config/srdwm/srd, which said the same thing twice. No other user-facing path had the same problem -- srdwm reads the config dir and writes nothing else. Cursor shapes. A client's own cursor surface is now rendered with the hotspot it declared, so an I-beam over text or a hand over a link shows the app's image instead of srdwm's arrow. The built-in arrow stays as the fallback when no client has set one, over decorations and the desktop. Named shapes still fall back to the arrow; most toolkits set a surface. Decorations and cursors now share one OverlayElement type, since render_output takes a single custom-element slice. Key repeat (srd.bind_repeat, Hyprland's binde). Held volume, brightness and switcher keys repeat at the seat's own rate rather than firing once. Driven from the poll loop, not a timer source: the winit backend has no calloop loop of its own, and poll_events already runs continuously in both backends. Repeat stops when *that* key is released, not when any key is. Always-on-top / pin, for the picture-in-picture and HUD rules that used it. Window::always_on_top was another declared-but-never-read field. Enforced in WindowManager's stacking order rather than at render time, so every consumer of stacking_order gets it and none can forget to honour it. Mouse-only window management, checked end to end: drag the titlebar to move, drag any edge or corner to resize, titlebar buttons to close/maximise/ minimise, click to focus, drag to a screen edge to snap, and now double-click the titlebar to maximise. The resize grab band went from 6px to 10px - a hairline is genuinely hard to hit with a mouse, which is why Hyprland ships extend_border_grab_area. Also removed the emoji status markers from docs/IMPLEMENTATION_STATUS.md. --- README.md | 4 +- crates/config/src/lib.rs | 70 +++++++++++++++++++++++++++++++ crates/core/src/manager.rs | 89 +++++++++++++++++++++++++++++++++++++++ crates/core/src/rules.rs | 2 + crates/core/src/window.rs | 11 ++++- crates/srdwm/src/main.rs | 10 +++-- crates/wayland/src/cursor.rs | 59 ++++++++++++++++++++------ crates/wayland/src/elements.rs | 20 +++++++++ crates/wayland/src/input.rs | 26 ++++++++++-- crates/wayland/src/lib.rs | 7 ++-- crates/wayland/src/state.rs | 94 +++++++++++++++++++++++++++++++++++++++++- crates/wayland/src/udev.rs | 11 +++-- crates/wayland/src/winit.rs | 15 ++++--- docs/ARCHITECTURE.md | 2 +- docs/DEFAULTS.md | 4 +- docs/IMPLEMENTATION_STATUS.md | 80 +++++++++++++++++++++++------------ 16 files changed, 438 insertions(+), 66 deletions(-) create mode 100644 crates/wayland/src/elements.rs diff --git a/README.md b/README.md index 618d5ff..55c4b85 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ DISPLAY=:99 SRDWM_CONFIG_PATH="$PWD/config/srd" cargo run -p srdwm ## Configuration -Config lives at `$SRDWM_CONFIG_PATH`, or `$XDG_CONFIG_HOME/srdwm/srd`, or -`~/.config/srdwm/srd`. [`config/srd/`](config/srd/) in this repo is a +Config lives at `$SRDWM_CONFIG_PATH`, or `$XDG_CONFIG_HOME/srd`, or +`~/.config/srd`. [`config/srd/`](config/srd/) in this repo is a complete working example (`init.lua` loads `keybindings.lua`, `layouts.lua`, `themes.lua`, `monitors.lua`, `rules.lua`, `startup.lua`). Full API and default values: [`docs/DEFAULTS.md`](docs/DEFAULTS.md). diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index ec8c7e8..6a0eeea 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -27,6 +27,9 @@ struct SharedState { wm: Rc>, values: HashMap, key_bindings: HashMap, + /// Combos registered with `srd.bind_repeat`, which fire repeatedly while + /// held (Hyprland's `binde`). A subset of `key_bindings`. + repeat_keys: std::collections::HashSet, /// 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 @@ -65,6 +68,7 @@ impl Engine { wm, values: default_config(), key_bindings: HashMap::new(), + repeat_keys: std::collections::HashSet::new(), event_handlers: HashMap::new(), config_dir: config_dir.into(), log: Vec::new(), @@ -154,6 +158,11 @@ impl Engine { self.state.borrow().key_bindings.keys().cloned().collect() } + /// Combos that should auto-repeat while held. + pub fn repeat_keys(&self) -> Vec { + self.state.borrow().repeat_keys.iter().cloned().collect() + } + fn register_srd_module(&self) -> Result<()> { let lua = &self.lua; let srd = lua.create_table()?; @@ -164,6 +173,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("bind_repeat", self.fn_bind_repeat()?)?; srd.set("on", self.fn_on()?)?; srd.set("rule", self.fn_rule()?)?; srd.set("load", self.fn_load()?)?; @@ -186,6 +196,7 @@ impl Engine { 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("toggle_pin", self.fn_window_action(WindowAction::TogglePin)?)?; window.set("focus", self.fn_window_focus_direction()?)?; window.set("move", self.fn_window_move_direction()?)?; window.set("next", self.fn_window_cycle(true)?)?; @@ -316,6 +327,20 @@ impl Engine { })?) } + /// `srd.bind_repeat(combo, fn)` - like `srd.bind`, but keeps firing + /// while the key is held (Hyprland's `binde`). For volume, brightness + /// and window-switcher cycling, where one step per press is unusable. + fn fn_bind_repeat(&self) -> Result> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| { + let key = lua.create_registry_value(f)?; + let mut s = state.borrow_mut(); + s.repeat_keys.insert(combo.clone()); + s.key_bindings.insert(combo, key); + Ok(()) + })?) + } + fn fn_bind(&self) -> Result> { let state = self.state.clone(); Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| { @@ -363,6 +388,7 @@ impl Engine { decorated: actions.get("decorated")?, border_color, border_width: actions.get("border_width")?, + pinned: actions.get("pinned")?, }, }; state.borrow().wm.borrow_mut().add_rule(rule); @@ -524,6 +550,7 @@ impl Engine { WindowAction::Maximize => wm.toggle_maximize(id), WindowAction::Fullscreen => wm.toggle_fullscreen(id), WindowAction::ToggleFloating => wm.toggle_floating(id), + WindowAction::TogglePin => wm.toggle_always_on_top(id), } } Ok(()) @@ -737,6 +764,7 @@ enum WindowAction { Maximize, Fullscreen, ToggleFloating, + TogglePin, } /// Recursively flattens a Lua table into dotted config keys, e.g. @@ -1148,4 +1176,46 @@ mod tests { engine.load_init().unwrap(); assert_eq!(engine.get("general.window_gap"), Some(ConfigValue::Number(4.0))); } + + #[test] + fn bind_repeat_registers_the_binding_and_marks_it_repeating() { + let dir = tempfile::tempdir().unwrap(); + let engine = engine_in(dir.path()); + engine + .lua + .load(r#" + srd.bind("Mod4+a", function() end) + srd.bind_repeat("XF86AudioRaiseVolume", function() end) + "#) + .exec() + .unwrap(); + + let bound = engine.bound_keys(); + // A repeating bind is still a normal binding - it must be grabbed + // and dispatched like any other, or it would never fire at all. + assert!(bound.contains(&"Mod4+a".to_string())); + assert!(bound.contains(&"XF86AudioRaiseVolume".to_string())); + + let repeat = engine.repeat_keys(); + assert_eq!(repeat, vec!["XF86AudioRaiseVolume".to_string()]); + assert!(!repeat.contains(&"Mod4+a".to_string()), "a plain bind must not repeat"); + } + + #[test] + fn bind_repeat_dispatches_like_a_normal_binding() { + let dir = tempfile::tempdir().unwrap(); + let engine = engine_in(dir.path()); + engine + .lua + .load(r#" + fired = 0 + srd.bind_repeat("Mod4+z", function() fired = fired + 1 end) + "#) + .exec() + .unwrap(); + assert!(engine.dispatch_keybinding("Mod4+z")); + assert!(engine.dispatch_keybinding("Mod4+z")); + let fired: i64 = engine.lua.globals().get("fired").unwrap(); + assert_eq!(fired, 2); + } } diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs index 23d94be..3856a25 100644 --- a/crates/core/src/manager.rs +++ b/crates/core/src/manager.rs @@ -192,6 +192,9 @@ impl WindowManager { if let Some(width) = a.border_width { window.border_width = width; } + if let Some(pinned) = a.pinned { + window.always_on_top = pinned; + } } if let Some(monitor) = self.primary_monitor() { @@ -211,6 +214,8 @@ impl WindowManager { self.windows.insert(id, window); self.order.push(id); self.focused = Some(id); + // A new window goes on top, but must not cover a pinned one. + self.restack_pinned(); if maximize { self.toggle_maximize(id); } @@ -251,6 +256,39 @@ impl WindowManager { let id = self.order.remove(pos); self.order.push(id); } + self.restack_pinned(); + } + + /// Toggles "always on top" for a window (Hyprland's `pin`), used for + /// picture-in-picture and small HUD overlays that must stay visible + /// while you work in something else. + pub fn toggle_always_on_top(&mut self, id: WindowId) { + if let Some(w) = self.windows.get_mut(&id) { + w.always_on_top = !w.always_on_top; + } + self.restack_pinned(); + } + + pub fn is_always_on_top(&self, id: WindowId) -> bool { + self.windows.get(&id).map(|w| w.always_on_top).unwrap_or(false) + } + + /// Moves every always-on-top window to the top of the stack, keeping + /// their relative order. + /// + /// `order` is the stacking order (last = topmost), so pinning is not a + /// property the renderer checks - it is maintained here, which means + /// every existing consumer of `stacking_order` gets it for free and + /// cannot forget to honour it. + fn restack_pinned(&mut self) { + if !self.windows.values().any(|w| w.always_on_top) { + return; + } + let (pinned, rest): (Vec<_>, Vec<_>) = self + .order + .iter() + .partition(|id| self.windows.get(id).is_some_and(|w| w.always_on_top)); + self.order = rest.into_iter().chain(pinned).collect(); } // ---- Focus ---------------------------------------------------------- @@ -1216,4 +1254,55 @@ mod tests { "the two windows should have traded places in the stack" ); } + + // ---- Always on top --------------------------------------------------- + + #[test] + fn pinned_windows_stay_above_newly_raised_ones() { + let mut wm = wm_with_monitor(); + let pinned = wm.alloc_window_id(); + wm.add_window(Window::new(pinned, "pip")); + let other = wm.alloc_window_id(); + wm.add_window(Window::new(other, "normal")); + + wm.toggle_always_on_top(pinned); + assert!(wm.is_always_on_top(pinned)); + assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned)); + + // Raising a normal window must not bury the pinned one. + wm.raise_window(other); + assert_eq!( + wm.stacking_order().last().map(|w| w.id), + Some(pinned), + "pinned window must remain topmost after another is raised" + ); + } + + #[test] + fn a_new_window_does_not_cover_a_pinned_one() { + let mut wm = wm_with_monitor(); + let pinned = wm.alloc_window_id(); + wm.add_window(Window::new(pinned, "pip")); + wm.toggle_always_on_top(pinned); + + let fresh = wm.alloc_window_id(); + wm.add_window(Window::new(fresh, "just opened")); + + assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned)); + } + + #[test] + fn unpinning_lets_a_window_fall_back_into_the_normal_stack() { + let mut wm = wm_with_monitor(); + 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.toggle_always_on_top(a); + assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(a)); + wm.toggle_always_on_top(a); + wm.raise_window(b); + assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(b)); + } } diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs index 4d881a5..642179d 100644 --- a/crates/core/src/rules.rs +++ b/crates/core/src/rules.rs @@ -47,6 +47,8 @@ pub struct WindowRuleActions { pub decorated: Option, pub border_color: Option<(u8, u8, u8)>, pub border_width: Option, + /// Always-on-top (Hyprland's `pin`). + pub pinned: Option, } #[derive(Debug, Clone, Default)] diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index 3970b02..9484709 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -50,8 +50,15 @@ impl Window { /// The height, in pixels, of the drawn title bar. Shared between backends so /// hit-testing and rendering agree on the same band. pub const TITLEBAR_HEIGHT: u32 = 30; -/// Width of a resize grab margin along each window edge. -pub const RESIZE_MARGIN: i32 = 6; +/// Width of the resize grab band along each window edge. +/// +/// 10px rather than a hairline: this is grabbed with a mouse, and a border +/// only a couple of pixels wide is genuinely hard to hit - which is why +/// Hyprland ships `extend_border_grab_area` and why every desktop widens +/// this beyond the visible border. The band is inside the window, so it +/// costs a few pixels of client edge; that is the right trade for making +/// resize reliably grabbable without a keyboard. +pub const RESIZE_MARGIN: i32 = 10; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResizeEdge { diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs index b59ae82..5fc762f 100644 --- a/crates/srdwm/src/main.rs +++ b/crates/srdwm/src/main.rs @@ -5,15 +5,19 @@ use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; +/// Where the Lua config lives: `$SRDWM_CONFIG_PATH`, else +/// `$XDG_CONFIG_HOME/srd`, else `~/.config/srd`. +/// +/// Just `srd`, not `srdwm/srd` - the extra level said the same thing twice. fn config_dir() -> PathBuf { if let Ok(p) = std::env::var("SRDWM_CONFIG_PATH") { return PathBuf::from(p); } if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - return PathBuf::from(xdg).join("srdwm/srd"); + return PathBuf::from(xdg).join("srd"); } if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join(".config/srdwm/srd"); + return PathBuf::from(home).join(".config/srd"); } PathBuf::from("config/srd") } @@ -92,7 +96,7 @@ fn main() -> Result<(), Box> { PlatformKind::Wayland => { let combos = engine.bound_keys(); log::info!("{} keybinding(s) will be intercepted from clients", combos.len()); - srdwm_wayland::connect(wm.clone(), &combos)? + srdwm_wayland::connect(wm.clone(), &combos, &engine.repeat_keys())? } #[cfg(windows)] PlatformKind::Windows => Box::new(srdwm_windows::WindowsPlatform::new()?), diff --git a/crates/wayland/src/cursor.rs b/crates/wayland/src/cursor.rs index 121e8e1..b489c03 100644 --- a/crates/wayland/src/cursor.rs +++ b/crates/wayland/src/cursor.rs @@ -7,13 +7,19 @@ //! 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. +//! Two sources, in priority order: +//! +//! 1. **The client's own cursor surface** (`CursorImageStatus::Surface`) -- +//! a terminal's I-beam, a browser's hand, an app's resize arrows. Drawn +//! from its surface tree, offset by the hotspot the client declared. +//! 2. **A built-in arrow**, for when no client has set an image (over +//! srdwm's own decorations and the desktop) or asked for a named shape +//! 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. //! //! The built-in arrow is deliberate rather than loading an XCursor theme: //! theme loading pulls in a dependency, needs a theme to actually be @@ -21,8 +27,11 @@ //! is always present beats a prettier one that sometimes isn't there - the //! same reasoning as `decoration.rs`'s font fallback. +use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; use smithay::utils::{Logical, Point}; +use crate::elements::OverlayElement; + /// Side length of the built-in cursor bitmap, in pixels. pub(crate) const CURSOR_SIZE: i32 = 24; @@ -85,6 +94,10 @@ pub(crate) fn arrow_bitmap() -> Vec { } +/// One cursor render element, whatever the source. +/// +/// Client cursor surfaces and the built-in bitmap are different element +/// types, so they're unified here rather than forcing both through one. /// 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 @@ -99,14 +112,17 @@ pub(crate) fn render_elements( pos: Point, origin: Point, size: (i32, i32), -) -> Vec> +) -> Vec> where - R: smithay::backend::renderer::Renderer + smithay::backend::renderer::ImportMem, + R: smithay::backend::renderer::Renderer + + smithay::backend::renderer::ImportAll + + smithay::backend::renderer::ImportMem, R::TextureId: Clone + Send + 'static, { - use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; + use smithay::backend::renderer::element::surface::render_elements_from_surface_tree; use smithay::backend::renderer::element::Kind; - use smithay::input::pointer::CursorImageStatus; + use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData}; + use smithay::wayland::compositor::with_states; if matches!(status, CursorImageStatus::Hidden) { return Vec::new(); @@ -117,10 +133,26 @@ where return Vec::new(); } - // Built-in arrow: hotspot is the tip, so no offset. + if let CursorImageStatus::Surface(surface) = status { + // The client picked an image. Its hotspot is the point *inside* that + // image which tracks the pointer, so the surface is drawn offset by + // it - without this the image sits down-right of where clicks land. + let hotspot = with_states(surface, |states| { + states + .data_map + .get::() + .map(|d| d.lock().unwrap().hotspot) + .unwrap_or_default() + }); + let at = (local.0 - hotspot.x, local.1 - hotspot.y); + 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); match MemoryRenderBufferRenderElement::from_buffer(renderer, at, buffer, None, None, None, Kind::Cursor) { - Ok(e) => vec![e], + Ok(e) => vec![OverlayElement::Memory(e)], Err(e) => { log::warn!("cursor: failed to import bitmap: {e}"); Vec::new() @@ -128,6 +160,7 @@ where } } + /// 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 { diff --git a/crates/wayland/src/elements.rs b/crates/wayland/src/elements.rs new file mode 100644 index 0000000..f1fbfee --- /dev/null +++ b/crates/wayland/src/elements.rs @@ -0,0 +1,20 @@ +//! The one render-element type for everything srdwm draws *itself*, on top +//! of client windows: its titlebars 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. + +use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; +use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement; +use smithay::backend::renderer::{ImportAll, ImportMem}; + +smithay::backend::renderer::element::render_elements! { + pub(crate) OverlayElement where + R: ImportAll + ImportMem; + /// A client's own surface, used for client-set cursor images. + Surface=WaylandSurfaceRenderElement, + /// A bitmap srdwm rasterised: a titlebar, or the built-in cursor arrow. + Memory=MemoryRenderBufferRenderElement, +} diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs index 6959c8f..17e94d4 100644 --- a/crates/wayland/src/input.rs +++ b/crates/wayland/src/input.rs @@ -206,7 +206,17 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32), + TitlebarHit::Drag => { + // Double-click the titlebar to maximise, as every other + // desktop does - one of the few window operations that + // otherwise needs the keyboard or a precise button hit. + if state.is_double_click(id, time) { + state.wm.borrow_mut().toggle_maximize(id); + state.sync_geometry(id); + } else { + state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32) + } + } TitlebarHit::Close => { if let Some(w) = state.id_to_window.get(&id) { close_dwindow(w); @@ -223,6 +233,8 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point { + if let Some((key_name, modifiers)) = matched { + state.begin_repeat(keycode, &key_name, modifiers); + state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers }); + } } + // Any release ends a repeat of *that* key; releasing an unrelated + // key must not stop it. + BackendKeyState::Released => state.end_repeat(keycode), } // Unmatched keys were already forwarded to the focused client by // `FilterResult::Forward` inside the closure above. diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs index 001c803..1d04d01 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -38,6 +38,7 @@ mod cursor; mod decoration; +mod elements; mod input; mod lock; mod protocols; @@ -67,13 +68,13 @@ pub(crate) fn err(e: impl std::fmt::Display) -> PlatformError { /// nested-vs-native. Falls back to winit if udev initialization fails for /// any reason (no seat access, no DRM device, ...), logging why rather than /// failing outright. -pub fn connect(wm: Rc>, bound_keys: &[String]) -> PlatformResult> { +pub fn connect(wm: Rc>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult> { let no_host_display = std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none(); if no_host_display { - match udev::UdevPlatform::connect(wm.clone(), bound_keys) { + match udev::UdevPlatform::connect(wm.clone(), bound_keys, repeat_keys) { Ok(platform) => return Ok(Box::new(platform)), Err(e) => log::warn!("udev/DRM backend unavailable ({e}); falling back to nested winit backend"), } } - Ok(Box::new(WaylandPlatform::connect(wm, bound_keys)?)) + Ok(Box::new(WaylandPlatform::connect(wm, bound_keys, repeat_keys)?)) } diff --git a/crates/wayland/src/state.rs b/crates/wayland/src/state.rs index a1704cb..bea3453 100644 --- a/crates/wayland/src/state.rs +++ b/crates/wayland/src/state.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; 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::renderer::element::memory::MemoryRenderBuffer; @@ -127,12 +127,18 @@ pub(crate) struct CompState { /// Bitmap for the built-in arrow, built once at startup rather than /// per frame. pub(crate) cursor_buffer: MemoryRenderBuffer, + /// Last titlebar press, for double-click detection. + pub(crate) last_titlebar_click: Option<(WindowId, u32)>, pub(crate) wm: Rc>, pub(crate) surface_to_id: HashMap, pub(crate) id_to_window: HashMap, pub(crate) decorations: HashMap, pub(crate) pending: Rc>>, pub(crate) bound_keys: Rc>, + /// Combos that repeat while held (`srd.bind_repeat`). + pub(crate) repeat_keys: Rc>, + /// The binding currently held down and repeating, if any. + pub(crate) repeat: Option, pub(crate) start_time: Instant, /// `Some` only for the udev/DRM backend; see `udev.rs` module docs for /// why its runtime state lives here rather than on a separate struct. @@ -147,6 +153,61 @@ pub(crate) struct CompState { pub(crate) xwayland_pending: Vec, } +/// A held keybinding that is firing repeatedly. +/// +/// Driven from the poll loop rather than a timer source: the winit backend +/// has no `calloop` loop of its own, and `poll_events` already runs +/// continuously in both backends, so this works the same in each. +pub(crate) struct RepeatState { + /// Which physical key is held - repeat stops when *this* key is + /// released, not when any key is. + pub(crate) keycode: smithay::input::keyboard::Keycode, + pub(crate) key_name: String, + pub(crate) modifiers: srdwm_core::Modifiers, + pub(crate) next_fire: Instant, +} + +/// Matches the seat's own repeat settings (`add_keyboard(.., 200, 25)`), so +/// held bindings feel the same as held keys in a text field. +const REPEAT_DELAY: Duration = Duration::from_millis(200); +const REPEAT_INTERVAL: Duration = Duration::from_millis(1000 / 25); + +impl CompState { + /// Starts repeating `combo` if it was registered with `srd.bind_repeat`. + pub(crate) fn begin_repeat(&mut self, keycode: smithay::input::keyboard::Keycode, key_name: &str, modifiers: srdwm_core::Modifiers) { + let combo = srdwm_core::key_combo_string(modifiers, key_name); + if !self.repeat_keys.contains(&combo) { + return; + } + self.repeat = Some(RepeatState { + keycode, + key_name: key_name.to_string(), + modifiers, + next_fire: Instant::now() + REPEAT_DELAY, + }); + } + + /// Stops repeating when the held key is released. + pub(crate) fn end_repeat(&mut self, keycode: smithay::input::keyboard::Keycode) { + if self.repeat.as_ref().is_some_and(|r| r.keycode == keycode) { + self.repeat = None; + } + } + + /// Emits another `KeyPress` if the held binding is due. Called once per + /// poll from both backends. + pub(crate) fn tick_repeat(&mut self) { + let Some(repeat) = self.repeat.as_mut() else { return }; + let now = Instant::now(); + if now < repeat.next_fire { + return; + } + repeat.next_fire = now + REPEAT_INTERVAL; + let (key_name, modifiers) = (repeat.key_name.clone(), repeat.modifiers); + self.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers }); + } +} + /// 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); @@ -219,6 +280,8 @@ impl CompState { // real Wayland keyboard/selection focus. (Same class of bug as the // click-to-focus one fixed earlier; this is the creation path.) self.set_keyboard_focus(Some(surface)); + // A newly-mapped window goes on top, but not over a pinned one. + self.raise_pinned(); self.pending.borrow_mut().push(CoreEvent::WindowCreated(id)); } @@ -339,6 +402,35 @@ impl CompState { keyboard.set_focus(self, surface, serial); } + /// 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 { + const DOUBLE_CLICK_MS: u32 = 400; + let doubled = match self.last_titlebar_click { + Some((last_id, last_time)) => last_id == id && time.saturating_sub(last_time) <= DOUBLE_CLICK_MS, + None => false, + }; + // Reset after a double, so a third click starts a fresh pair rather + // than counting as another double. + self.last_titlebar_click = if doubled { None } else { Some((id, time)) }; + doubled + } + + /// Re-raises always-on-top windows in the `Space`. + /// + /// `WindowManager` keeps pinned windows last in its own stacking order, + /// but the `Space` has an order of its own that decides what actually + /// draws on top - so pinning is only real once it is pushed here. + /// Called after anything that raises a window. + pub(crate) fn raise_pinned(&mut self) { + let pinned: Vec = self.wm.borrow().stacking_order().filter(|w| w.always_on_top).map(|w| w.id).collect(); + for id in pinned { + if let Some(w) = self.id_to_window.get(&id).cloned() { + self.space.raise_element(&w, false); + } + } + } + pub(crate) fn sync_geometry(&mut self, id: WindowId) { let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { return }; if let Some(w) = self.id_to_window.get(&id) { diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs index 30517f8..95ef571 100644 --- a/crates/wayland/src/udev.rs +++ b/crates/wayland/src/udev.rs @@ -197,7 +197,7 @@ impl CompState { let head = &mut udev.heads[index]; let back = 1 - head.front; - let mut custom_elements: Vec> = Vec::new(); + let mut custom_elements: Vec> = Vec::new(); if !locked { // Cursor first: `render_output` draws custom elements // front-to-back, so the earliest element is topmost. On a @@ -215,7 +215,7 @@ impl CompState { 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) { - Ok(elem) => custom_elements.push(elem), + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), Err(e) => log::warn!("udev: failed to import titlebar buffer: {e}"), } } @@ -451,7 +451,7 @@ pub struct UdevPlatform { } impl UdevPlatform { - pub fn connect(wm: Rc>, bound_keys: &[String]) -> PlatformResult { + pub fn connect(wm: Rc>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult { let event_loop: EventLoop<'static, CompState> = EventLoop::try_new().map_err(err)?; let (session, notifier) = LibSeatSession::new().map_err(err)?; @@ -556,12 +556,15 @@ impl UdevPlatform { lock: Default::default(), cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), cursor_buffer: crate::cursor::make_buffer(), + last_titlebar_click: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), decorations: HashMap::new(), pending: pending.clone(), bound_keys: Rc::new(bound_keys.iter().cloned().collect::>()), + repeat_keys: Rc::new(repeat_keys.iter().cloned().collect::>()), + repeat: None, start_time: Instant::now(), udev: Some(udev_state), xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState::new::(&display_handle), @@ -897,6 +900,8 @@ impl Platform for UdevPlatform { fn poll_events(&mut self) -> PlatformResult> { self.accept_clients()?; self.event_loop.dispatch(Some(Duration::from_millis(16)), &mut self.state).map_err(err)?; + // Held bindings that repeat - see `CompState::tick_repeat`. + self.state.tick_repeat(); self.display.dispatch_clients(&mut self.state).map_err(err)?; self.display.flush_clients().map_err(err)?; self.state.render_udev_frame(); diff --git a/crates/wayland/src/winit.rs b/crates/wayland/src/winit.rs index b21ca06..a11e263 100644 --- a/crates/wayland/src/winit.rs +++ b/crates/wayland/src/winit.rs @@ -64,7 +64,7 @@ impl WaylandPlatform { /// strings (see `srdwm_core::key_combo_string`) - the same set the X11 /// backend grabs individually via `XGrabKey`. Only a keypress matching /// one of these is withheld from the focused client. - pub fn connect(wm: Rc>, bound_keys: &[String]) -> PlatformResult { + pub fn connect(wm: Rc>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult { let display: Display = Display::new().map_err(err)?; let dh = display.handle(); @@ -132,12 +132,15 @@ impl WaylandPlatform { lock: SessionLock::default(), cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), cursor_buffer: crate::cursor::make_buffer(), + last_titlebar_click: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), decorations: HashMap::new(), pending: pending.clone(), bound_keys: Rc::new(bound_keys.iter().cloned().collect()), + repeat_keys: Rc::new(repeat_keys.iter().cloned().collect()), + repeat: None, start_time: Instant::now(), udev: None, xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState::new::(&dh), @@ -212,11 +215,11 @@ impl WaylandPlatform { return Ok(()); } - let mut custom_elements: Vec> = Vec::new(); + let mut custom_elements: Vec> = 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) { - Ok(elem) => custom_elements.push(elem), + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), Err(e) => log::warn!("failed to import titlebar buffer for window {id}: {e}"), } } @@ -274,11 +277,11 @@ impl WaylandPlatform { renderer.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into()).map_err(err)?; let mut framebuffer = renderer.bind(&mut target).map_err(err)?; - let mut custom_elements: Vec> = Vec::new(); + let mut custom_elements: Vec> = Vec::new(); for (&id, deco) in self.state.decorations.iter() { let Some(geom) = self.wm.borrow().window(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(elem); + custom_elements.push(crate::elements::OverlayElement::Memory(elem)); } } @@ -334,6 +337,8 @@ impl Platform for WaylandPlatform { if closed { return Err(PlatformError::Other("compositor window closed".into())); } + // Held bindings that repeat - see `CompState::tick_repeat`. + self.state.tick_repeat(); self.display.dispatch_clients(&mut self.state).map_err(err)?; self.display.flush_clients().map_err(err)?; self.render_frame()?; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 61d036c..c4a4a4b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,5 +114,5 @@ returns `nil` for a documented key), then `Engine::load_init()` executes rest - `srd.load(name)` reads and executes `config_dir/{name}.lua` in the same Lua state, so later files can see earlier `srd.bind()`/`srd.set()` calls. Config directory resolution order: `$SRDWM_CONFIG_PATH`, then -`$XDG_CONFIG_HOME/srdwm/srd`, then `~/.config/srdwm/srd` (matching +`$XDG_CONFIG_HOME/srd`, then `~/.config/srd` (matching `docs/DEFAULTS.md`'s documented location). diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md index 74dfba6..d645413 100644 --- a/docs/DEFAULTS.md +++ b/docs/DEFAULTS.md @@ -253,7 +253,7 @@ srd.set("platform.macos.accessibility_enabled", true) -- Default: true ## Configuration File Locations ### Linux -- **Config**: `~/.config/srdwm/srd/` +- **Config**: `~/.config/srd/` - **Themes**: `~/.config/srdwm/themes/` - **Scripts**: `~/.config/srdwm/scripts/` - **Cache**: `~/.cache/srdwm/` @@ -267,7 +267,7 @@ srd.set("platform.macos.accessibility_enabled", true) -- Default: true - **Logs**: `%LOCALAPPDATA%\srdwm\logs\` ### macOS -- **Config**: `~/Library/Application Support/srdwm/srd/` +- **Config**: `~/Library/Application Support/srdwm/` - **Themes**: `~/Library/Application Support/srdwm/themes/` - **Scripts**: `~/Library/Application Support/srdwm/scripts/` - **Cache**: `~/Library/Caches/srdwm/` diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 6b2579b..efecf4a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -6,7 +6,7 @@ This mirrors the style of the legacy C++ project's own status doc (now at `cargo clippy --workspace`) and, where applicable, actually run and observed doing the thing described - not just "the code compiles and looks right." -## βœ… Complete and verified +## Complete and verified ### Core window/workspace/layout engine (`crates/core`) - `WindowManager`: window/workspace/monitor state, focus cycling, directional @@ -93,7 +93,7 @@ doing the thing described - not just "the code compiles and looks right." use the public Accessibility API plus an overlay window for decorations on macOS, not private APIs. -## πŸ”„ Wayland backend (`crates/wayland`) - real, more limited scope than X11 +## Wayland backend (`crates/wayland`) - real, more limited scope than X11 This is the one piece with essentially no working prior art to port (see `docs/PRIOR_ART.md`): the legacy C++ never wired a single event listener. @@ -118,30 +118,30 @@ responsibility: security-relevant invariant spans state, protocol handling and rendering at once, so splitting it across three files would have hidden it. -- βœ… Runs via smithay's winit backend (nested window), initializes EGL/GLES, +- Runs via smithay's winit backend (nested window), initializes EGL/GLES, advertises a real Wayland socket, and was verified to start, initialize rendering, and run its event loop without crashing (log-verified; a full visual confirmation the way X11 got one was skipped deliberately -- see below). -- βœ… xdg-shell toplevels are tracked through the *same* +- xdg-shell toplevels are tracked through the *same* `srdwm_core::WindowManager` the X11 backend uses - new windows get a real `WindowId`, go through `SmartPlacement`/`MasterStackLayout` exactly like X11 windows do. -- βœ… xdg-decoration is negotiated to server-side mode. -- βœ… Pointer click/drag/resize on the decoration band uses the identical +- xdg-decoration is negotiated to server-side mode. +- Pointer click/drag/resize on the decoration band uses the identical `hit_test` code path as X11. -- βœ… Decorations render actual title text (`crates/wayland/src/decoration.rs`): +- Decorations render actual title text (`crates/wayland/src/decoration.rs`): glyphs rasterized via `fontdue` against whatever monospace font is found under `/usr/share/fonts` etc. (falls back to solid-color-only, same as before, if none is found), uploaded per-frame through smithay's `MemoryRenderBuffer`. Pure `(width, height, text) -> Vec` function, unit-tested without any GL/display context. -- βœ… Global keybindings are matched precisely: `WaylandPlatform::connect` +- Global keybindings are matched precisely: `WaylandPlatform::connect` takes the config's actual bound-key combo strings (same format/shared `srdwm_core::keysyms` table the X11 backend's `XGrabKey` calls use) and only a matching keypress is withheld from the focused client - no more "any Super-held key is ours" heuristic. -- βœ… DRM/udev backend (`crates/wayland/src/udev.rs`): runs as the real +- DRM/udev backend (`crates/wayland/src/udev.rs`): runs as the real compositor on a bare TTY, no host session to nest under. Single primary GPU, first connected connector, its first-listed mode, real `libseat` session/seat handling (VT-switch pause/resume, no raw root-only @@ -164,7 +164,7 @@ once, so splitting it across three files would have hidden it. renders. No client-side visual check yet (the VM has no Wayland-native client installed to test against, only X11 ones - see below). No hotplug (connectors or GPUs) after startup. -- βœ… XWayland integration (`crates/wayland/src/xwayland.rs`), udev/DRM +- XWayland integration (`crates/wayland/src/xwayland.rs`), udev/DRM backend only (the winit backend would need its own `calloop::EventLoop` added first - see the module's doc comment): spawns XWayland, starts `X11Wm`, and implements `XwmHandler`/`XWaylandShellHandler` to bridge @@ -222,7 +222,7 @@ once, so splitting it across three files would have hidden it. - Not implemented: selections/clipboard, XSETTINGS, RandR primary-output sync, override-redirect window geometry beyond initial placement (all have harmless no-op default `XwmHandler` methods). -- βœ… **`wlr-layer-shell-unstable-v1`** (`WlrLayerShellHandler`, `delegate_layer_shell!` +- **`wlr-layer-shell-unstable-v1`** (`WlrLayerShellHandler`, `delegate_layer_shell!` in `lib.rs`): layer surfaces are mapped into the output's `smithay::desktop::LayerMap` (`layer_map_for_output`), which `render_output` renders automatically - no rendering-path changes were needed, only state @@ -266,13 +266,13 @@ once, so splitting it across three files would have hidden it. binary against the user's real Hyprland session (which advertises xdg-output and doesn't crash it) side by side with the trace against `srdwm`. -- βœ… **xdg-output (`zxdg_output_manager_v1`)**: added via smithay's +- **xdg-output (`zxdg_output_manager_v1`)**: added via smithay's `OutputManagerState::new_with_xdg_output`, piggybacking on the existing `delegate_output!`/`OutputHandler` wiring (no new handler trait needed). Not itself in the original "biggest blocker" list, but found to be a hard requirement in practice while fixing layer-shell above - see the wofi segfault account. -- βœ… **Clipboard**: `wl_data_device_manager`, `zwp_primary_selection_v1`, +- **Clipboard**: `wl_data_device_manager`, `zwp_primary_selection_v1`, and `zwlr_data_control_manager_v1`, all three sharing smithay's single `SelectionHandler`. Data-control is the one that matters most for this user's session: `wl-paste --watch cliphist store` (in their Hyprland @@ -296,7 +296,7 @@ once, so splitting it across three files would have hidden it. keystrokes and could not paste until it was clicked. (Same class as the click-to-focus bug fixed in the XWayland pass; this was the creation path.) -- βœ… **`ext-session-lock-v1`** (screen locking): `SessionLockHandler` with +- **`ext-session-lock-v1`** (screen locking): `SessionLockHandler` with per-output lock surfaces. `locked` gates both rendering (only the lock surface, over an opaque black clear - no windows, decorations, or layer surfaces) and input (all keys go to the lock surface, and **no key is @@ -310,8 +310,8 @@ once, so splitting it across three files would have hidden it. client (no locker - hyprlock/swaylock/etc. - is installed on this machine, so there was nothing else to test against; the user's `~/.scripts/lock` currently falls through to `loginctl lock-session`): - lock β†’ cleared frame β†’ `locked` confirmation β†’ lock surface configured to - the real output size β†’ `unlock_and_destroy` β†’ normal operation restored. + lock cleared frame `locked` confirmation lock surface configured to + the real output size `unlock_and_destroy` normal operation restored. Three properties were checked by counting protocol events delivered to a real `wezterm` launched at each point: - unlocked: 1 `wl_keyboard.enter` (control); @@ -323,7 +323,7 @@ once, so splitting it across three files would have hidden it. unconditionally, so merely opening a window at a locked screen handed it keyboard focus. The guard now lives in `set_keyboard_focus` itself, as the single chokepoint every focus path goes through. -- βœ… **`wlr-screencopy-unstable-v1`** (`crates/wayland/src/screencopy.rs`): +- **`wlr-screencopy-unstable-v1`** (`crates/wayland/src/screencopy.rs`): what `grim` uses, and therefore what the user's `Print` / `Alt+Print` binds (`grim`, `slurp | grim -g -`) and `wf-recorder` need. smithay 0.7 ships **no** helper for this protocol, so the `GlobalDispatch`/`Dispatch` @@ -342,7 +342,7 @@ once, so splitting it across three files would have hidden it. writes nothing. One real bug was found and fixed by this testing: reading back the winit backend's **EGL window surface** destroyed the GL context on the first - capture (`eglSwapBuffers: BAD_SURFACE` β†’ `BAD_ALLOC` β†’ "context has been + capture (`eglSwapBuffers: BAD_SURFACE` `BAD_ALLOC` "context has been lost", taking the whole compositor down), root-caused by A/B-ing the identical build with only the readback call removed. The winit path now renders a second pass into an offscreen `GlesRenderbuffer` and reads @@ -353,7 +353,7 @@ once, so splitting it across three files would have hidden it. protocol version 2 for that reason) and cursor overlay (`overlay_cursor` is accepted and ignored - this backend draws no cursor of its own yet). -- βœ… **Multi-monitor** (udev/DRM backend). Every connected connector becomes +- **Multi-monitor** (udev/DRM backend). Every connected connector becomes a `UdevHead` with its **own** scanout buffers, damage tracker and page-flip state, laid out left-to-right in a shared global coordinate space; the `PixmanRenderer` is shared, since they are one GPU. A head @@ -361,7 +361,7 @@ once, so splitting it across three files would have hidden it. its own page-flip event arrives (matched by CRTC), so monitors at different refresh rates each run at their own pace instead of the slowest gating the rest. - Connectorβ†’CRTC assignment never reuses a CRTC, so a machine with more + ConnectorCRTC assignment never reuses a CRTC, so a machine with more monitors than CRTCs drives as many as the hardware allows and logs the rest. Modes are chosen by the `PREFERRED` flag rather than list order. The rest of the compositor reaches outputs through @@ -384,12 +384,12 @@ once, so splitting it across three files would have hidden it. (= `[0.05, 0.05, 0.08]`) - i.e. both are really being rendered and scanned out, not just enumerated; - a window forced by `srd.rule` to **global** x=1500 appeared on head 1 at - head-local x=**220** (= 1500 βˆ’ 1280, the exact translation) with its + head-local x=**220** (= 1500 1280, the exact translation) with its srdwm titlebar, while head 0 stayed completely empty (0 of 64000 sampled pixels differed from the clear colour). The nested winit backend remains single-output by construction (it is one window on a host compositor). -- βœ… **Connector hotplug**. A `UdevBackend` event source watches for the +- **Connector hotplug**. A `UdevBackend` event source watches for the kernel's `change` uevent on the DRM device; `CompState::reprobe_outputs` then re-probes connectors (forcing a fresh probe - on a hotplug the cached status is exactly what has gone stale) and reconciles the head @@ -409,11 +409,11 @@ once, so splitting it across three files would have hidden it. (positions of the others change too). **Verified live in the QEMU VM**, booting with one connector and toggling the second at runtime: - - plug in β†’ `hotplug - 0 output(s) removed, 1 added`, + - plug in `hotplug - 0 output(s) removed, 1 added`, `output Virtual-2 connected (1024x768)`, `monitor layout changed: 2 monitor(s)`, and a screendump of the new head showed it really rendering at its own resolution; - - unplug β†’ `1 output(s) removed, 0 added`, back to 1 monitor, compositor + - unplug `1 output(s) removed, 0 added`, back to 1 monitor, compositor healthy; - **window rescue**: an xterm placed by rule at global x=1500 (on the second monitor) was still visible after that monitor was unplugged -- @@ -513,7 +513,7 @@ 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 +- **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 @@ -526,16 +526,42 @@ built them: **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 +- **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** +- **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. +- **Cursor shapes**: a client's own cursor surface is now rendered with + its declared hotspot, so an I-beam over text or a hand over a link shows + the app's image rather than srdwm's arrow. The built-in arrow remains the + fallback for when no client has set one (over decorations and the + desktop). Named shapes (`CursorIcon::Text` etc.) still fall back to the + arrow; most toolkits set a surface, so this is rarely visible. +- **Key repeat for bindings** (`srd.bind_repeat`, Hyprland's `binde`). + Held volume/brightness keys and switcher cycling now repeat at the seat's + own rate (200ms delay, 25/s). Driven from the poll loop rather than a + timer source, because the winit backend has no `calloop` loop of its own + and `poll_events` already runs continuously in both. +- **Always-on-top / pin** (`srd.window.toggle_pin`, and `pinned = true` + as a window rule - their picture-in-picture and HUD rules use it). + `Window::always_on_top` was another declared-but-never-read field. + Enforced in `WindowManager`'s stacking order rather than at render time, + so every consumer of `stacking_order` gets it and none can forget it. +- **Mouse-only window management** works without touching the keyboard: + drag the titlebar to move, drag any edge or corner to resize, the + titlebar buttons to close/maximise/minimise, click to focus, drag to a + screen edge to snap (half/quarter/maximise), and **double-click the + titlebar to maximise**. The resize grab band was widened from 6px to + 10px - a hairline border is genuinely hard to hit with a mouse, which is + why Hyprland ships `extend_border_grab_area`. +- **Config path** is now `~/.config/srd` (or `$XDG_CONFIG_HOME/srd`), + not `~/.config/srdwm/srd` - the extra level said the same thing twice. + ## Not implemented anywhere yet All three protocols originally identified as blocking srdwm-wayland from -- cgit v1.2.3