srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-05-29 14:40:00 +0200
committersrdusr <[email protected]>2024-05-29 14:40:00 +0200
commit3d3057ae384ef7389284af8988410889e99c6bb9 (patch)
tree57b05e9660afd8ed51b26e5db1ab541a811ccc16
parentdd31bf5ac2a692617d478478d36c733586b93cf8 (diff)
downloadsrdwm-3d3057ae384ef7389284af8988410889e99c6bb9.tar.gz
srdwm-3d3057ae384ef7389284af8988410889e99c6bb9.zip
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.
-rw-r--r--README.md4
-rw-r--r--crates/config/src/lib.rs70
-rw-r--r--crates/core/src/manager.rs89
-rw-r--r--crates/core/src/rules.rs2
-rw-r--r--crates/core/src/window.rs11
-rw-r--r--crates/srdwm/src/main.rs10
-rw-r--r--crates/wayland/src/cursor.rs59
-rw-r--r--crates/wayland/src/elements.rs20
-rw-r--r--crates/wayland/src/input.rs26
-rw-r--r--crates/wayland/src/lib.rs7
-rw-r--r--crates/wayland/src/state.rs94
-rw-r--r--crates/wayland/src/udev.rs11
-rw-r--r--crates/wayland/src/winit.rs15
-rw-r--r--docs/ARCHITECTURE.md2
-rw-r--r--docs/DEFAULTS.md4
-rw-r--r--docs/IMPLEMENTATION_STATUS.md80
16 files changed, 438 insertions, 66 deletions
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<RefCell<WindowManager>>,
values: HashMap<String, ConfigValue>,
key_bindings: HashMap<String, RegistryKey>,
+ /// Combos registered with `srd.bind_repeat`, which fire repeatedly while
+ /// held (Hyprland's `binde`). A subset of `key_bindings`.
+ repeat_keys: std::collections::HashSet<String>,
/// 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<String> {
+ 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<mlua::Function<'_>> {
+ 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<mlua::Function<'_>> {
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<bool>,
pub border_color: Option<(u8, u8, u8)>,
pub border_width: Option<u32>,
+ /// Always-on-top (Hyprland's `pin`).
+ pub pinned: Option<bool>,
}
#[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<dyn std::error::Error>> {
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<u8> {
}
+/// 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<R>(
pos: Point<f64, Logical>,
origin: Point<i32, Logical>,
size: (i32, i32),
-) -> Vec<smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement<R>>
+) -> Vec<OverlayElement<R>>
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::<CursorImageSurfaceData>()
+ .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<R> where
+ R: ImportAll + ImportMem;
+ /// A client's own surface, used for client-set cursor images.
+ Surface=WaylandSurfaceRenderElement<R>,
+ /// A bitmap srdwm rasterised: a titlebar, or the built-in cursor arrow.
+ Memory=MemoryRenderBufferRenderElement<R>,
+}
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<f64, Logic
if let Some((id, hit)) = hit {
focus_window(state, id);
match hit {
- TitlebarHit::Drag => 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<f64, Logic
if let Some((window, _loc)) = state.space.element_under(pos) {
let window = window.clone();
state.space.raise_element(&window, true);
+ // Clicking a normal window must not bury a pinned one.
+ state.raise_pinned();
if let Some(&id) = dwindow_wl_surface(&window).and_then(|s| state.surface_to_id.get(&s)) {
focus_window(state, id);
}
@@ -276,10 +288,16 @@ pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend
}
});
- if key_state == BackendKeyState::Pressed {
- if let Some((key_name, modifiers)) = matched {
- state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers });
+ match key_state {
+ BackendKeyState::Pressed => {
+ 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<RefCell<WindowManager>>, bound_keys: &[String]) -> PlatformResult<Box<dyn Platform>> {
+pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult<Box<dyn Platform>> {
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<RefCell<WindowManager>>,
pub(crate) surface_to_id: HashMap<WlSurface, WindowId>,
pub(crate) id_to_window: HashMap<WindowId, DWindow>,
pub(crate) decorations: HashMap<WindowId, MemoryRenderBuffer>,
pub(crate) pending: Rc<RefCell<Vec<CoreEvent>>>,
pub(crate) bound_keys: Rc<HashSet<String>>,
+ /// Combos that repeat while held (`srd.bind_repeat`).
+ pub(crate) repeat_keys: Rc<HashSet<String>>,
+ /// The binding currently held down and repeating, if any.
+ pub(crate) repeat: Option<RepeatState>,
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<smithay::xwayland::X11Surface>,
}
+/// 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<WindowId> = 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<MemoryRenderBufferRenderElement<PixmanRenderer>> = Vec::new();
+ let mut custom_elements: Vec<crate::elements::OverlayElement<PixmanRenderer>> = 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<RefCell<WindowManager>>, bound_keys: &[String]) -> PlatformResult<Self> {
+ pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult<Self> {
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::<HashSet<_>>()),
+ repeat_keys: Rc::new(repeat_keys.iter().cloned().collect::<HashSet<_>>()),
+ repeat: None,
start_time: Instant::now(),
udev: Some(udev_state),
xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState::new::<CompState>(&display_handle),
@@ -897,6 +900,8 @@ impl Platform for UdevPlatform {
fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> {
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<RefCell<WindowManager>>, bound_keys: &[String]) -> PlatformResult<Self> {
+ pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult<Self> {
let display: Display<CompState> = Display::new().map_err(err)?;
let dh = display.handle();
@@ -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::<CompState>(&dh),
@@ -212,11 +215,11 @@ impl WaylandPlatform {
return Ok(());
}
- let mut custom_elements: Vec<MemoryRenderBufferRenderElement<GlesRenderer>> = Vec::new();
+ let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new();
for (&id, deco) in self.state.decorations.iter() {
let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue };
match MemoryRenderBufferRenderElement::from_buffer(renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) {
- 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<MemoryRenderBufferRenderElement<GlesRenderer>> = Vec::new();
+ let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new();
for (&id, deco) in self.state.decorations.iter() {
let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue };
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<u8>` 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