From dd31bf5ac2a692617d478478d36c733586b93cf8 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Tue, 14 May 2024 00:42:00 +0200 Subject: Draw a cursor, handle the lid, and everything a real config needs Groundwork for actually daily-driving this: porting the user's Hyprland config exposed what srdwm couldn't yet express, and testing on a bare TTY exposed something worse. A visible mouse cursor. Nothing drew a pointer at all - on a bare TTY the mouse was simply invisible. It hid because the nested backend runs inside another compositor, which draws a cursor over srdwm's window; only the DRM backend, i.e. the actual session path, was affected. A built-in arrow is now composited above everything on the output the pointer is on. It's a reviewable ASCII bitmap rather than an XCursor theme: a cursor that is always present beats a prettier one that sometimes isn't, the same reasoning as decoration.rs's font fallback. Client-set cursor surfaces and named shapes are still not rendered, so an app asking for an I-beam gets the arrow. Lid switch. libinput switch events are handled and surfaced to config as srd.on("lid_closed"/"lid_open", fn), so closing the lid can lock and suspend instead of doing nothing. Config-driven additions, each needed by a binding in the ported config and none of which existed: fullscreen (Window.fullscreen was a dead field -- declared, never read or written), directional window move that swaps with the neighbour and reorders the stack so tiling follows, focus cycling, modifier+drag to move/resize anywhere in a window rather than only by the titlebar, modifier+scroll to change workspace, and 8 XF86 media/power keysyms taken from the system's own XF86keysym.h. The keysym tables are hand-maintained in both directions and a key missing from either fails silently, so a round-trip test now covers every one the configs bind. Verified in the QEMU VM: a bare-TTY screendump shows a recognisable arrow at the pointer position (113 white fill + 58 black outline pixels at screen centre, where the pointer starts). --- crates/wayland/src/cursor.rs | 171 ++++++++++++++++++++++++++++++++++++++++ crates/wayland/src/input.rs | 67 ++++++++++++++++ crates/wayland/src/lib.rs | 1 + crates/wayland/src/protocols.rs | 7 +- crates/wayland/src/state.rs | 7 ++ crates/wayland/src/udev.rs | 44 ++++++++++- crates/wayland/src/winit.rs | 2 + 7 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 crates/wayland/src/cursor.rs (limited to 'crates/wayland/src') diff --git a/crates/wayland/src/cursor.rs b/crates/wayland/src/cursor.rs new file mode 100644 index 0000000..121e8e1 --- /dev/null +++ b/crates/wayland/src/cursor.rs @@ -0,0 +1,171 @@ +//! Mouse cursor rendering. +//! +//! The nested (winit) backend gets a cursor for free - the *host* +//! compositor draws one over srdwm's window - which is exactly why this was +//! missing for so long without being noticed. On a bare TTY nothing else is +//! drawing anything, so without this the pointer is simply invisible: you +//! can move it, click with it, and drag windows with it, but you cannot see +//! where it is. That makes the udev backend unusable as a real session. +//! +//! **Scope, stated plainly:** this draws one built-in arrow, always. It +//! honours `CursorImageStatus::Hidden` (so a client that hides the pointer +//! still gets its way), but it does *not* yet render a client's own cursor +//! surface or a named shape - an app asking for an I-beam or a resize arrow +//! still sees this arrow. That is a real limitation, and a visible one over +//! text fields; it is also strictly better than the previous behaviour of +//! drawing nothing at all. +//! +//! The built-in arrow is deliberate rather than loading an XCursor theme: +//! theme loading pulls in a dependency, needs a theme to actually be +//! installed, and has a search-path fallback story of its own. A cursor that +//! is always present beats a prettier one that sometimes isn't there - the +//! same reasoning as `decoration.rs`'s font fallback. + +use smithay::utils::{Logical, Point}; + +/// Side length of the built-in cursor bitmap, in pixels. +pub(crate) const CURSOR_SIZE: i32 = 24; + +/// A classic left-pointing arrow: white fill, black outline, with the +/// hotspot at (0, 0) - the tip. +/// +/// Encoded as a small bitmap rather than drawn with geometry so the shape is +/// obvious and reviewable: `.` transparent, `#` black outline, `*` white +/// fill. 24 rows of 24 columns. +const ARROW: [&str; CURSOR_SIZE as usize] = [ + "#.......................", + "##......................", + "#*#.....................", + "#**#....................", + "#***#...................", + "#****#..................", + "#*****#.................", + "#******#................", + "#*******#...............", + "#********#..............", + "#*********#.............", + "#**********#............", + "#***********#...........", + "#************#..........", + "#*************#.........", + "#******####### .........", + "#***#**#................", + "#**#.#**#...............", + "#*#..#**#...............", + "##....#**#..............", + "#.....#**#..............", + ".......###..............", + "........................", + "........................", +]; + +/// Rasterises the built-in arrow as premultiplied ARGB8888, the format +/// `MemoryRenderBuffer` expects. +pub(crate) fn arrow_bitmap() -> Vec { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + for (y, row) in ARROW.iter().enumerate() { + for (x, ch) in row.chars().enumerate() { + if x >= CURSOR_SIZE as usize || y >= CURSOR_SIZE as usize { + break; + } + // Premultiplied: opaque pixels only, so colour == colour * 1. + let (b, g, r, a) = match ch { + '#' => (0x00, 0x00, 0x00, 0xff), + '*' => (0xff, 0xff, 0xff, 0xff), + _ => continue, + }; + let i = (y * CURSOR_SIZE as usize + x) * 4; + buf[i] = b; + buf[i + 1] = g; + buf[i + 2] = r; + buf[i + 3] = a; + } + } + buf +} + + +/// Render elements for the pointer, to be drawn above everything else. +/// +/// `pos` is in the global space and `origin` is the output's origin, since +/// each head renders in its own coordinate space. +/// +/// Returns nothing when the cursor is hidden, or when the pointer is not +/// over this output - otherwise every monitor would draw its own copy. +pub(crate) fn render_elements( + status: &smithay::input::pointer::CursorImageStatus, + buffer: &smithay::backend::renderer::element::memory::MemoryRenderBuffer, + renderer: &mut R, + pos: Point, + origin: Point, + size: (i32, i32), +) -> Vec> +where + R: smithay::backend::renderer::Renderer + smithay::backend::renderer::ImportMem, + R::TextureId: Clone + Send + 'static, +{ + use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; + use smithay::backend::renderer::element::Kind; + use smithay::input::pointer::CursorImageStatus; + + if matches!(status, CursorImageStatus::Hidden) { + return Vec::new(); + } + // Only the output the pointer is actually on draws it. + let local = (pos.x as i32 - origin.x, pos.y as i32 - origin.y); + if local.0 < 0 || local.1 < 0 || local.0 >= size.0 || local.1 >= size.1 { + return Vec::new(); + } + + // Built-in arrow: hotspot is the tip, so no offset. + let at = (local.0 as f64, local.1 as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, at, buffer, None, None, None, Kind::Cursor) { + Ok(e) => vec![e], + Err(e) => { + log::warn!("cursor: failed to import bitmap: {e}"); + Vec::new() + } + } +} + +/// The built-in arrow as an uploadable buffer. Built once at startup rather +/// than per frame - the bitmap never changes. +pub(crate) fn make_buffer() -> smithay::backend::renderer::element::memory::MemoryRenderBuffer { + use smithay::backend::allocator::Fourcc; + use smithay::backend::renderer::element::memory::MemoryRenderBuffer; + use smithay::utils::Transform; + MemoryRenderBuffer::from_slice(&arrow_bitmap(), Fourcc::Argb8888, (CURSOR_SIZE, CURSOR_SIZE), 1, Transform::Normal, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arrow_is_the_expected_size_and_has_an_opaque_tip() { + let buf = arrow_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + // The hotspot pixel (0,0) is the arrow's tip and must be visible, + // otherwise the cursor appears offset from where clicks land. + assert_eq!(buf[3], 0xff, "tip pixel must be opaque"); + } + + #[test] + fn arrow_has_both_outline_and_fill() { + let buf = arrow_bitmap(); + let mut black = 0; + let mut white = 0; + for px in buf.chunks_exact(4) { + if px[3] == 0 { + continue; + } + if px[0] == 0 && px[1] == 0 && px[2] == 0 { + black += 1; + } else { + white += 1; + } + } + assert!(black > 20, "expected a black outline, got {black} px"); + assert!(white > 40, "expected a white fill, got {white} px"); + } +} diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs index 76607ee..6959c8f 100644 --- a/crates/wayland/src/input.rs +++ b/crates/wayland/src/input.rs @@ -20,6 +20,11 @@ use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, Layer}; use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId}; +/// Modifier that turns a drag anywhere in a window into move/resize. +/// Matches the `SUPER` the shipped and ported configs use for +/// `bindm ... movewindow` / `resizewindow`. +const DRAG_MODIFIER: Modifiers = Modifiers::SUPER; + use crate::state::CompState; pub(crate) fn last_pointer_pos(state: &CompState) -> Point { @@ -134,6 +139,7 @@ pub(crate) fn focus_window(state: &mut CompState, id: WindowId) { pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point, button: u32, pressed: bool, time: u32) { const BTN_LEFT: u32 = 0x110; + const BTN_RIGHT: u32 = 0x111; let serial = SERIAL_COUNTER.next_serial(); // Locked: forward the click to the lock surface (it may have a button or @@ -146,6 +152,33 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point(state: &mut CompState, event: &E) -> bool +where + B: smithay::backend::input::InputBackend, + E: smithay::backend::input::PointerAxisEvent, +{ + use smithay::backend::input::Axis; + + if state.lock.locked { + return false; + } + let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state())); + if !mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) { + return false; + } + let Some(v) = event.amount(Axis::Vertical).filter(|v| *v != 0.0) else { return false }; + + let mut wm = state.wm.borrow_mut(); + let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect(); + if ids.is_empty() { + return false; + } + let current = ids.iter().position(|&id| id == wm.current_workspace()).unwrap_or(0); + // Scrolling down (positive) advances, matching `workspace, e+1`. + let next = if v > 0.0 { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() }; + wm.switch_workspace(ids[next]); + true +} diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs index 053e6ad..001c803 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -36,6 +36,7 @@ //! - xdg-decoration is forced to server-side mode (`Mode::ServerSide`) so //! well-behaved clients don't also draw their own client-side titlebar. +mod cursor; mod decoration; mod input; mod lock; diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs index 582a622..f51b35d 100644 --- a/crates/wayland/src/protocols.rs +++ b/crates/wayland/src/protocols.rs @@ -131,7 +131,12 @@ impl SeatHandler for CompState { } fn focus_changed(&mut self, _seat: &Seat, _focused: Option<&WlSurface>) {} - fn cursor_image(&mut self, _seat: &Seat, _image: CursorImageStatus) {} + /// Clients set their own cursor (an I-beam over text, a hand over a + /// link). Recorded here and drawn by the render paths - on a bare TTY + /// nothing else would draw it. See `cursor.rs`. + fn cursor_image(&mut self, _seat: &Seat, image: CursorImageStatus) { + self.cursor_status = image; + } } impl WlrLayerShellHandler for CompState { diff --git a/crates/wayland/src/state.rs b/crates/wayland/src/state.rs index 6bc6f1a..a1704cb 100644 --- a/crates/wayland/src/state.rs +++ b/crates/wayland/src/state.rs @@ -120,6 +120,13 @@ pub(crate) struct CompState { /// content is never rendered and input never reaches normal clients -- /// see `SessionLockHandler` below. pub(crate) lock: SessionLock, + /// What the pointer should look like, as set by the focused client (or + /// the default when no client has said). See `cursor.rs` for why this + /// has to be drawn by us on the DRM backend. + pub(crate) cursor_status: smithay::input::pointer::CursorImageStatus, + /// Bitmap for the built-in arrow, built once at startup rather than + /// per frame. + pub(crate) cursor_buffer: MemoryRenderBuffer, pub(crate) wm: Rc>, pub(crate) surface_to_id: HashMap, pub(crate) id_to_window: HashMap, diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs index aca4865..30517f8 100644 --- a/crates/wayland/src/udev.rs +++ b/crates/wayland/src/udev.rs @@ -159,6 +159,9 @@ impl CompState { // Drained before the `&mut self.udev` borrow below, so screencopy can // be serviced with the renderer that borrow owns. let mut captures = std::mem::take(&mut self.screencopy_pending); + // Same reason: the cursor needs the renderer that borrow owns. + let cursor_status = self.cursor_status.clone(); + let cursor_buffer = self.cursor_buffer.clone(); // Which heads are eligible, and what each needs, gathered before the // mutable borrow of `self.udev`. @@ -196,6 +199,19 @@ impl CompState { let mut custom_elements: Vec> = Vec::new(); if !locked { + // Cursor first: `render_output` draws custom elements + // front-to-back, so the earliest element is topmost. On a + // bare TTY nothing else draws a pointer - see `cursor.rs`. + let pointer_pos = udev.pointer_pos; + let hsize = udev.heads[index].size; + custom_elements.extend(crate::cursor::render_elements( + &cursor_status, + &cursor_buffer, + &mut udev.renderer, + pointer_pos, + origin, + hsize, + )); for (geom, deco) in &decorations { let pos = ((geom.x - origin.x) as f64, (geom.y - origin.y) as f64); match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, deco, None, None, None, Kind::Unspecified) { @@ -538,6 +554,8 @@ impl UdevPlatform { _screencopy_state: crate::screencopy::ScreencopyState::new::(&display_handle), screencopy_pending: Vec::new(), lock: Default::default(), + cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), + cursor_buffer: crate::cursor::make_buffer(), wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), @@ -832,10 +850,30 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent { + // Fully qualified: libinput's own `Switch` is also in scope here. + use smithay::backend::input::{SwitchState, SwitchToggleEvent}; + if matches!(event.switch(), Some(smithay::reexports::input::event::switch::Switch::Lid)) { + let closed = event.state() == SwitchState::On; + log::info!("lid {}", if closed { "closed" } else { "opened" }); + state.pending.borrow_mut().push(CoreEvent::LidSwitch { closed }); + } + } InputEvent::PointerAxis { event } => { - // Scroll: forwarded to the focused client via the pointer axis - // frame, no WM-level handling (matches the winit backend, which - // doesn't handle scroll either). + // Modifier+scroll switches workspace instead of reaching the + // client - the `bind = SUPER, mouse_down/up, workspace, e+1/e-1` + // gesture. Checked first so the client never sees these events; + // forwarding them too would scroll the window under the cursor + // as a side effect of changing workspace. + if crate::input::handle_workspace_scroll(state, &event) { + return; + } + // Otherwise: forwarded to the focused client via the pointer axis + // frame, no WM-level handling. let Some(pointer) = state.seat.get_pointer() else { return }; let source = event.source(); let mut frame = AxisFrame::new(event.time_msec()).source(source); diff --git a/crates/wayland/src/winit.rs b/crates/wayland/src/winit.rs index 85be223..b21ca06 100644 --- a/crates/wayland/src/winit.rs +++ b/crates/wayland/src/winit.rs @@ -130,6 +130,8 @@ impl WaylandPlatform { _screencopy_state: screencopy::ScreencopyState::new::(&dh), screencopy_pending: Vec::new(), lock: SessionLock::default(), + cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), + cursor_buffer: crate::cursor::make_buffer(), wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), -- cgit v1.2.3