diff options
| author | srdusr <[email protected]> | 2024-05-29 14:40:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-05-29 14:40:00 +0200 |
| commit | 3d3057ae384ef7389284af8988410889e99c6bb9 (patch) | |
| tree | 57b05e9660afd8ed51b26e5db1ab541a811ccc16 /crates | |
| parent | dd31bf5ac2a692617d478478d36c733586b93cf8 (diff) | |
| download | srdwm-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.rs | 70 | ||||
| -rw-r--r-- | crates/core/src/manager.rs | 89 | ||||
| -rw-r--r-- | crates/core/src/rules.rs | 2 | ||||
| -rw-r--r-- | crates/core/src/window.rs | 11 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 10 | ||||
| -rw-r--r-- | crates/wayland/src/cursor.rs | 59 | ||||
| -rw-r--r-- | crates/wayland/src/elements.rs | 20 | ||||
| -rw-r--r-- | crates/wayland/src/input.rs | 26 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 7 | ||||
| -rw-r--r-- | crates/wayland/src/state.rs | 94 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 11 | ||||
| -rw-r--r-- | crates/wayland/src/winit.rs | 15 |
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()?; |