srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
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 /crates
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.
Diffstat (limited to 'crates')
-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
12 files changed, 380 insertions, 34 deletions
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()?;