From d91200560a390029e5294ab157e3e712a9786240 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 5 Jul 2026 10:29:32 -0700 Subject: [PATCH] chore: more work on cursor refactors --- .../src/compositor/cursor.rs | 61 +++++++++++--- .../src/compositor/handlers.rs | 17 +++- .../src/compositor/input.rs | 23 +++--- .../src/compositor/input/pointer.rs | 14 ++++ .../src/compositor/mod.rs | 6 ++ .../src/compositor/output.rs | 15 ---- .../src/compositor/output/layout.rs | 24 +++++- .../src/compositor/rendering.rs | 80 +++++++++++++++++-- .../src/compositor/runtime.rs | 32 +++++--- .../src/compositor/udev.rs | 6 +- .../src/compositor/udev/device/cursor.rs | 78 +++++++++++++++--- todos/CURSOR.md | 16 +++- 12 files changed, 297 insertions(+), 75 deletions(-) diff --git a/crates/hearthspace-compositor/src/compositor/cursor.rs b/crates/hearthspace-compositor/src/compositor/cursor.rs index ff9ba00..fc8cbcc 100644 --- a/crates/hearthspace-compositor/src/compositor/cursor.rs +++ b/crates/hearthspace-compositor/src/compositor/cursor.rs @@ -19,6 +19,9 @@ pub(crate) enum CursorIcon { } pub(super) struct SoftwareCursor { + /// The named shape this buffer was rasterized for, so the render path can + /// rebuild the buffer only when the desired shape changes. + pub(super) icon: CursorIcon, pub(super) buffer: MemoryRenderBuffer, pub(super) hotspot: (i32, i32), } @@ -31,13 +34,9 @@ pub(in crate::compositor) struct CursorImage { } const DEFAULT_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/default"); -#[cfg(feature = "udev")] const NS_RESIZE_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/ns-resize"); -#[cfg(feature = "udev")] const EW_RESIZE_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/ew-resize"); -#[cfg(feature = "udev")] const NWSE_RESIZE_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/nwse-resize"); -#[cfg(feature = "udev")] const NESW_RESIZE_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/nesw-resize"); const DESIRED_CURSOR_SIZE: u32 = 24; @@ -53,7 +52,14 @@ pub(super) fn cursor_icon_from_smithay(icon: SmithayCursorIcon) -> CursorIcon { } pub(super) fn standard_software_cursor() -> SoftwareCursor { - let image = standard_cursor_image(); + software_cursor_for_icon(CursorIcon::Default) +} + +/// Rasterize the software cursor buffer for a named shape at native (unscaled) +/// size. The whole scene is rescaled by the output scale at render time, so this +/// buffer stays scale-1 and its hotspot is expressed in native cursor pixels. +pub(super) fn software_cursor_for_icon(icon: CursorIcon) -> SoftwareCursor { + let image = cursor_image_for_icon(icon); let width = i32::try_from(image.width).expect("cursor width must fit in i32"); let height = i32::try_from(image.height).expect("cursor height must fit in i32"); @@ -67,17 +73,16 @@ pub(super) fn standard_software_cursor() -> SoftwareCursor { ); SoftwareCursor { + icon, buffer, hotspot: image.hotspot, } } -#[cfg(feature = "udev")] pub(in crate::compositor) fn cursor_image_for_icon(icon: CursorIcon) -> CursorImage { cursor_image_for_icon_at_size(icon, DESIRED_CURSOR_SIZE) } -#[cfg(feature = "udev")] pub(in crate::compositor) fn cursor_image_for_icon_at_size( icon: CursorIcon, desired_size: u32, @@ -93,10 +98,6 @@ pub(in crate::compositor) fn cursor_image_for_icon_at_size( } } -pub(in crate::compositor) fn standard_cursor_image() -> CursorImage { - cursor_image_from_xcursor(DEFAULT_CURSOR, DESIRED_CURSOR_SIZE) -} - fn cursor_image_from_xcursor(contents: &[u8], desired_size: u32) -> CursorImage { let images = parse_xcursor(contents).expect("packaged cursor must be valid Xcursor data"); let image = images @@ -116,3 +117,41 @@ fn cursor_image_from_xcursor(contents: &[u8], desired_size: u32) -> CursorImage pixels_rgba: image.pixels_rgba.clone(), } } + +#[cfg(test)] +mod tests { + use super::*; + + const NAMED_ICONS: [CursorIcon; 5] = [ + CursorIcon::Default, + CursorIcon::NsResize, + CursorIcon::EwResize, + CursorIcon::NwseResize, + CursorIcon::NeswResize, + ]; + + #[test] + fn every_named_cursor_builds_a_software_buffer() { + // The software cursor path is now used by every backend (headless, the + // native software fallback, and client-surface presentation), so each + // packaged shape must rasterize without panicking. + for icon in NAMED_ICONS { + let cursor = software_cursor_for_icon(icon); + assert_eq!(cursor.icon, icon); + } + } + + #[test] + fn cursor_images_carry_hotspot_and_pixels() { + for icon in NAMED_ICONS { + let image = cursor_image_for_icon(icon); + assert!(image.width > 0 && image.height > 0); + assert_eq!( + image.pixels_rgba.len(), + (image.width * image.height * 4) as usize + ); + assert!(image.hotspot.0 >= 0 && image.hotspot.0 as u32 <= image.width); + assert!(image.hotspot.1 >= 0 && image.hotspot.1 as u32 <= image.height); + } + } +} diff --git a/crates/hearthspace-compositor/src/compositor/handlers.rs b/crates/hearthspace-compositor/src/compositor/handlers.rs index 59300a8..e5446cf 100644 --- a/crates/hearthspace-compositor/src/compositor/handlers.rs +++ b/crates/hearthspace-compositor/src/compositor/handlers.rs @@ -452,15 +452,26 @@ impl SeatHandler for App { fn cursor_image(&mut self, _seat: &Seat, image: CursorImageStatus) { match image { - CursorImageStatus::Hidden => self.cursor_visible = false, + CursorImageStatus::Hidden => { + self.cursor_visible = false; + self.cursor_surface = None; + } CursorImageStatus::Named(icon) => { self.cursor_visible = true; + self.cursor_surface = None; self.cursor_icon = crate::compositor::cursor::cursor_icon_from_smithay(icon); } - CursorImageStatus::Surface(_) => { + CursorImageStatus::Surface(surface) => { + // A focused client wants to draw its own cursor. Present the + // client surface (with its committed hotspot) as a software + // cursor; the backend cursor policy falls back to software + // presentation whenever a client surface is active. self.cursor_visible = true; - self.cursor_icon = crate::compositor::cursor::CursorIcon::Default; + self.cursor_surface = Some(surface); } } + // The cursor changed, so a frame is needed to update software cursor + // presentation (and to hand the new shape to the backend cursor policy). + self.request_redraw(); } } diff --git a/crates/hearthspace-compositor/src/compositor/input.rs b/crates/hearthspace-compositor/src/compositor/input.rs index 0fc6481..077ad24 100644 --- a/crates/hearthspace-compositor/src/compositor/input.rs +++ b/crates/hearthspace-compositor/src/compositor/input.rs @@ -48,19 +48,18 @@ pub(in crate::compositor) fn handle_input_event( } InputEvent::PointerMotion { event } => { trace!(delta = ?event.delta(), time = event.time_msec(), "relative pointer motion input"); - let scale = f64::from(state.output_scale_at(state.pointer_location).max(1)); - let delta = Point::from((event.delta().x / scale, event.delta().y / scale)); - if scale != 1.0 { - debug!( - pointer = ?state.pointer_location, - raw_delta = ?event.delta(), - logical_delta = ?delta, - scale, - "scaled relative pointer motion by output scale" - ); - } + // libinput reports accelerated relative deltas already in the + // compositor's logical coordinate space, so they are applied + // directly to the logical pointer location (as Smithay's reference + // compositor does). They must NOT be divided by the output scale: + // the pointer position is logical, and monitor scale only governs + // how that logical position is *presented* (cursor plane, surface + // buffers), not how far a physical motion moves the pointer. Scaling + // the delta made the HiDPI pointer track at 1/scale speed, so the + // rendered cursor drifted away from where input was actually being + // routed. state.apply_pointer_motion( - relative_pointer_location(state.raw_pointer_location, delta), + relative_pointer_location(state.raw_pointer_location, event.delta()), event.time_msec(), ); } diff --git a/crates/hearthspace-compositor/src/compositor/input/pointer.rs b/crates/hearthspace-compositor/src/compositor/input/pointer.rs index 107659f..3992f63 100644 --- a/crates/hearthspace-compositor/src/compositor/input/pointer.rs +++ b/crates/hearthspace-compositor/src/compositor/input/pointer.rs @@ -243,6 +243,8 @@ impl App { y: drag.window_start.y + (delta.y / self.viewport_scale).round() as i32, }; let window_id = drag.window_id; + // The compositor owns the cursor while dragging window chrome. + self.cursor_surface = None; if let Some(window) = self.window_mut_by_id(window_id) { window.position = new_position; trace!(window_id, ?new_position, "updated window drag position"); @@ -254,11 +256,20 @@ impl App { if let Some(resize) = self.resize.as_ref() { let edges = resize.edges; self.cursor_icon = resize_cursor_icon(edges); + // The compositor owns the cursor while resizing. + self.cursor_surface = None; self.update_resize(self.raw_pointer_location); return; } let hit = self.hit_test(self.pointer_location); + // A client only owns the cursor while the pointer is over its content; + // over compositor chrome, resize borders, or the canvas the compositor's + // named cursor takes over, so any lingering client cursor surface is + // dropped here. + if !matches!(hit, Some(HitTarget::Client { .. })) { + self.cursor_surface = None; + } self.cursor_icon = match &hit { Some(HitTarget::ResizeBorder { edges, .. }) => resize_cursor_icon(*edges), _ => CursorIcon::Default, @@ -304,6 +315,9 @@ impl App { } let hit = self.hit_test(self.pointer_location); + if !matches!(hit, Some(HitTarget::Client { .. })) { + self.cursor_surface = None; + } self.cursor_icon = match &hit { Some(HitTarget::ResizeBorder { edges, .. }) => resize_cursor_icon(*edges), _ => CursorIcon::Default, diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index 06d84bc..3782271 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -180,6 +180,11 @@ struct App { /// (which owns the winit window, a sibling of this handler state). cursor_icon: CursorIcon, cursor_visible: bool, + /// Client-provided cursor surface set through `wl_pointer.set_cursor`, when a + /// focused client wants to draw its own cursor (e.g. Firefox's text I-beam). + /// When present it takes precedence over `cursor_icon` and is presented as a + /// software cursor with the client-supplied hotspot. + cursor_surface: Option, next_spawn_position: CanvasPoint, next_spawn_output_name: Option, spawn_offset: i32, @@ -487,6 +492,7 @@ pub(in crate::compositor) fn initialize_app( resize: None, cursor_icon: CursorIcon::Default, cursor_visible: true, + cursor_surface: None, next_spawn_position: CanvasPoint { x: 80, y: 96 }, next_spawn_output_name: None, spawn_offset: 0, diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index e3a47f1..f20094a 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -295,14 +295,6 @@ impl App { self.outputs.primary.scale.max(1) } - pub(super) fn output_scale_at(&self, point: Point) -> i32 { - std::iter::once(&self.outputs.primary) - .chain(self.outputs.secondary.iter()) - .find(|output| point_in_rect(point, output.logical_rect())) - .map(|output| output.scale.max(1)) - .unwrap_or_else(|| self.primary_output_scale()) - } - pub(in crate::compositor) fn set_output_layout(&mut self, layout: OutputLayout) -> bool { self.outputs.set_layout(layout) } @@ -524,13 +516,6 @@ fn scaled_client_dimension(size: i32, scale: i32) -> i32 { ((size + scale - 1) / scale).max(1) } -fn point_in_rect(point: Point, rect: Rectangle) -> bool { - point.x >= f64::from(rect.loc.x) - && point.y >= f64::from(rect.loc.y) - && point.x < f64::from(rect.loc.x + rect.size.w) - && point.y < f64::from(rect.loc.y + rect.size.h) -} - fn rectangles_overlap(a: Rectangle, b: Rectangle) -> bool { a.loc.x < b.loc.x + b.size.w && a.loc.x + a.size.w > b.loc.x diff --git a/crates/hearthspace-compositor/src/compositor/output/layout.rs b/crates/hearthspace-compositor/src/compositor/output/layout.rs index 379c54f..c4df50f 100644 --- a/crates/hearthspace-compositor/src/compositor/output/layout.rs +++ b/crates/hearthspace-compositor/src/compositor/output/layout.rs @@ -39,7 +39,8 @@ impl OutputLayout { } pub(super) fn scale_for(&self, output_name: &str, role_id: &str) -> Option { - self.monitor_for(output_name, role_id, true) + let use_role_fallback = !default_placeholder_monitors(&self.monitors); + self.monitor_for(output_name, role_id, use_role_fallback) .map(|monitor| monitor.scale.max(1)) } @@ -594,6 +595,27 @@ mod tests { assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); } + #[test] + fn output_layout_keeps_backend_scale_when_monitors_are_placeholders() { + // Default placeholder monitors ("primary"/"secondary", scale 1) must not + // override a differently-named backend output's own scale through the + // "primary" role fallback. Regression: `scale_for` hardcoded role + // fallback, so `--headless-scale`/native scale was silently reset to 1. + let primary = OutputGeometry { + name: "hearthspace-0".into(), + size: Size::::from((1280, 720)), + scale: 2, + }; + + let plan = output_locations_for_layout( + primary, + vec![], + &OutputLayout::from_monitors(hearthspace_ipc::Settings::default().monitors), + ); + + assert_eq!(plan.primary_scale, 2); + } + #[test] fn output_layout_places_scaled_outputs_by_logical_size() { let plan = output_locations_for_layout( diff --git a/crates/hearthspace-compositor/src/compositor/rendering.rs b/crates/hearthspace-compositor/src/compositor/rendering.rs index 2c8e4c9..af90f5f 100644 --- a/crates/hearthspace-compositor/src/compositor/rendering.rs +++ b/crates/hearthspace-compositor/src/compositor/rendering.rs @@ -13,12 +13,14 @@ use smithay::{ gles::GlesRenderer, }, desktop::PopupManager, - utils::{Logical, Physical, Point, Rectangle, Size}, + input::pointer::CursorImageAttributes, + utils::{IsAlive, Logical, Physical, Point, Rectangle, Size}, wayland::{ compositor::{SurfaceAttributes, TraversalAction, with_states, with_surface_tree_downward}, shell::xdg::SurfaceCachedState, }, }; +use std::sync::Mutex; use wayland_server::protocol::wl_surface; use super::{App, ManagedWindowKind, masonry_titlebar, windows::toplevel_title}; @@ -140,8 +142,8 @@ impl App { self.refresh_normal_window_outputs(); let mut elements = Vec::new(); - if let Some(element) = self.software_cursor_element(renderer, output_rect) { - elements.push(HearthspaceRenderElement::from(element)); + for element in self.software_cursor_elements(renderer, output_rect) { + elements.push(element); } for index in (0..self.windows.len()).rev() { @@ -177,16 +179,75 @@ impl App { elements } - fn software_cursor_element( + /// Build the cursor render elements for the current frame, front-to-back. + /// + /// The software cursor is only used when the active backend has opted into + /// it (`software_cursor_visible`): headless always, the native backend as a + /// fallback when the hardware cursor plane is unavailable, and either + /// backend whenever a focused client is drawing its own cursor surface. A + /// client cursor surface takes precedence over the compositor's named shape + /// and is positioned by its own committed hotspot. Both paths are built at + /// native scale; the surrounding [`render_frame_at`](Self::render_frame_at) + /// rescales the whole scene by the output scale, so the cursor scales with + /// monitor DPI and its hotspot always lands on the logical pointer position. + fn software_cursor_elements( &mut self, renderer: &mut GlesRenderer, output_rect: Rectangle, - ) -> Option> { - if !self.software_cursor_visible { - return None; + ) -> Vec { + if !self.software_cursor_visible || !self.cursor_visible { + return Vec::new(); } if !point_in_logical_rect(self.pointer_location, output_rect) { - return None; + return Vec::new(); + } + + if let Some(surface) = self.cursor_surface.clone() { + if surface.alive() { + let hotspot = with_states(&surface, |states| { + states + .data_map + .get::>() + .map(|attributes| { + attributes + .lock() + .expect("cursor image attributes poisoned") + .hotspot + }) + .unwrap_or_default() + }); + let location = Point::::from(( + self.pointer_location.x.round() as i32 - hotspot.x, + self.pointer_location.y.round() as i32 - hotspot.y, + )); + let surface_elements: Vec> = + render_elements_from_surface_tree( + renderer, + &surface, + location, + 1.0, + 1.0, + Kind::Cursor, + ); + return surface_elements + .into_iter() + .map(|element| { + HearthspaceRenderElement::from(RescaleRenderElement::from_element( + element, location, 1.0, + )) + }) + .collect(); + } + // The client destroyed its cursor surface; drop it and fall back to + // the compositor's named cursor below. + self.cursor_surface = None; + } + + // Named shape: rebuild the cursor buffer only when the desired icon + // changes, so steady-state motion reuses the cached rasterization. + if self.software_cursor.icon != self.cursor_icon { + self.software_cursor = + crate::compositor::cursor::software_cursor_for_icon(self.cursor_icon); } let location = Point::::from(( @@ -204,6 +265,9 @@ impl App { Kind::Cursor, ) .ok() + .map(HearthspaceRenderElement::from) + .into_iter() + .collect() } /// Collect render elements for every popup anchored to the given window. diff --git a/crates/hearthspace-compositor/src/compositor/runtime.rs b/crates/hearthspace-compositor/src/compositor/runtime.rs index c38454f..9373482 100644 --- a/crates/hearthspace-compositor/src/compositor/runtime.rs +++ b/crates/hearthspace-compositor/src/compositor/runtime.rs @@ -189,28 +189,42 @@ impl CalloopData { } fn update_native_hardware_cursor(&mut self) { - #[cfg(feature = "udev")] - if let Backend::Udev(backend) = &mut self.backend { - backend.update_hardware_cursor(&self.state); + let CalloopData { state, backend, .. } = self; + match backend { + #[cfg(feature = "udev")] + Backend::Udev(device) => device.update_hardware_cursor(state), + #[cfg(feature = "winit")] + Backend::Winit(_) => { + // The host window system draws the cursor for named shapes; only + // a client-drawn cursor surface needs software presentation. + state.software_cursor_visible = state.cursor_surface.is_some(); + } + Backend::Headless(_) => { + // The headless output has no external cursor plane, so the cursor + // is always composited into the framebuffer (screenshots/tests). + state.software_cursor_visible = true; + } } } /// Push the compositor's desired cursor to the host winit window, but only /// when it differs from the cursor currently shown. fn apply_cursor_icon(&mut self) { + // The host cursor only shows named shapes; when a client is drawing its + // own cursor surface the host cursor is hidden and the surface is + // presented in software instead. + let host_cursor_visible = self.state.cursor_visible && self.state.cursor_surface.is_none(); if self.applied_cursor == self.state.cursor_icon - && self.applied_cursor_visible == self.state.cursor_visible + && self.applied_cursor_visible == host_cursor_visible { return; } - trace!(from = ?self.applied_cursor, to = ?self.state.cursor_icon, visible = self.state.cursor_visible, "applying cursor icon"); + trace!(from = ?self.applied_cursor, to = ?self.state.cursor_icon, visible = host_cursor_visible, "applying cursor icon"); self.applied_cursor = self.state.cursor_icon; - self.applied_cursor_visible = self.state.cursor_visible; + self.applied_cursor_visible = host_cursor_visible; #[cfg(feature = "winit")] if let Backend::Winit(backend) = &self.backend { - backend - .window() - .set_cursor_visible(self.state.cursor_visible); + backend.window().set_cursor_visible(host_cursor_visible); backend .window() .set_cursor(smithay::reexports::winit::cursor::Cursor::Icon( diff --git a/crates/hearthspace-compositor/src/compositor/udev.rs b/crates/hearthspace-compositor/src/compositor/udev.rs index ee81b94..864f2cd 100644 --- a/crates/hearthspace-compositor/src/compositor/udev.rs +++ b/crates/hearthspace-compositor/src/compositor/udev.rs @@ -348,12 +348,16 @@ fn insert_libinput_source( } impl UdevBackendState { - pub(in crate::compositor) fn update_hardware_cursor(&mut self, state: &super::App) { + pub(in crate::compositor) fn update_hardware_cursor(&mut self, state: &mut super::App) { if self.drm_commits_paused { return; } if let Some(device) = self.primary_device.as_mut() { device.update_hardware_cursor(state); + } else { + // No native device owns a cursor plane, so the cursor can only be + // presented in software (composited into the scene). + state.software_cursor_visible = true; } } diff --git a/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs b/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs index 08b3b3c..b5ae745 100644 --- a/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs +++ b/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs @@ -28,17 +28,29 @@ pub(super) struct HardwareCursor { } impl UdevDevice { - pub(in crate::compositor::udev) fn update_hardware_cursor(&mut self, state: &App) { - let Some(cursor) = self.hardware_cursor.as_mut() else { + pub(in crate::compositor::udev) fn update_hardware_cursor(&mut self, state: &mut App) { + // Software presentation is required when a focused client is drawing its + // own cursor surface, or when this device never obtained a usable + // hardware cursor plane. In those cases the cursor is composited into the + // scene by the render path, so any lingering hardware cursor is cleared. + if state.cursor_surface.is_some() || self.hardware_cursor.is_none() { + state.software_cursor_visible = true; + if let Some(active_crtc) = self.hardware_cursor_crtc.take() { + clear_legacy_cursor(&self.scanout_node, active_crtc); + } + self.hardware_cursor_position = None; return; - }; + } + if !state.cursor_visible { + state.software_cursor_visible = false; if let Some(active_crtc) = self.hardware_cursor_crtc.take() { clear_legacy_cursor(&self.scanout_node, active_crtc); } self.hardware_cursor_position = None; return; } + let pointer = state.pointer_location; let target = self.output_surfaces.iter().find_map(|output| { let view = state.output_render_view(output.target.connector_name())?; @@ -54,6 +66,7 @@ impl UdevDevice { }); let Some((crtc, local, scale)) = target else { + state.software_cursor_visible = false; if let Some(active_crtc) = self.hardware_cursor_crtc.take() { clear_legacy_cursor(&self.scanout_node, active_crtc); } @@ -61,10 +74,21 @@ impl UdevDevice { return; }; + let cursor = self + .hardware_cursor + .as_mut() + .expect("hardware cursor presence checked above"); + + // Track whether every DRM cursor operation succeeded. A driver can reject + // a cursor buffer or move at runtime, in which case the compositor falls + // back to software presentation for this frame rather than losing the + // cursor entirely. + let mut hardware_ok = true; + if cursor.icon != state.cursor_icon || cursor.scale != scale { update_hardware_cursor_image(&self.scanout_node, cursor, state.cursor_icon, scale); if let Some(crtc) = self.hardware_cursor_crtc { - set_legacy_cursor(&self.scanout_node, crtc, cursor); + hardware_ok &= set_legacy_cursor(&self.scanout_node, crtc, cursor); } self.hardware_cursor_position = None; } @@ -86,31 +110,61 @@ impl UdevDevice { if let Some(active_crtc) = self.hardware_cursor_crtc { clear_legacy_cursor(&self.scanout_node, active_crtc); } - set_legacy_cursor(&self.scanout_node, crtc, cursor); + hardware_ok &= set_legacy_cursor(&self.scanout_node, crtc, cursor); self.hardware_cursor_crtc = Some(crtc); self.hardware_cursor_position = None; } if self.hardware_cursor_position != Some(position) { - move_legacy_cursor(&self.scanout_node, crtc, position); + hardware_ok &= move_legacy_cursor(&self.scanout_node, crtc, position); self.hardware_cursor_position = Some(position); } + + // If any DRM cursor operation failed, present the cursor in software for + // this frame instead of leaving it stuck or invisible. + state.software_cursor_visible = !hardware_ok; + if !hardware_ok { + if let Some(active_crtc) = self.hardware_cursor_crtc.take() { + clear_legacy_cursor(&self.scanout_node, active_crtc); + } + self.hardware_cursor_position = None; + } } } -fn set_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle, cursor: &HardwareCursor) { +fn set_legacy_cursor( + scanout_node: &ScanoutNode, + crtc: crtc::Handle, + cursor: &HardwareCursor, +) -> bool { #[allow(deprecated)] match scanout_node.drm_fd.set_cursor(crtc, Some(&cursor.buffer)) { - Ok(()) => debug!(?crtc, "set legacy DRM cursor"), - Err(error) => debug!(?crtc, %error, "legacy DRM cursor set failed"), + Ok(()) => { + debug!(?crtc, "set legacy DRM cursor"); + true + } + Err(error) => { + debug!(?crtc, %error, "legacy DRM cursor set failed"); + false + } } } -fn move_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle, position: (i32, i32)) { +fn move_legacy_cursor( + scanout_node: &ScanoutNode, + crtc: crtc::Handle, + position: (i32, i32), +) -> bool { #[allow(deprecated)] match scanout_node.drm_fd.move_cursor(crtc, position) { - Ok(()) => debug!(?crtc, ?position, "moved legacy DRM cursor"), - Err(error) => debug!(?crtc, ?position, %error, "legacy DRM cursor move failed"), + Ok(()) => { + debug!(?crtc, ?position, "moved legacy DRM cursor"); + true + } + Err(error) => { + debug!(?crtc, ?position, %error, "legacy DRM cursor move failed"); + false + } } } diff --git a/todos/CURSOR.md b/todos/CURSOR.md index 3d2f7d1..54d1315 100644 --- a/todos/CURSOR.md +++ b/todos/CURSOR.md @@ -1,7 +1,17 @@ -- [ ] Automatic hardware-to-software fallback is not wired yet. -- [ ] Software cursor shape changes are not wired yet. -- [ ] Client-provided Wayland cursor images are ignored. +- [x] Automatic hardware-to-software fallback is wired: the native backend + presents the cursor in software when a device has no usable hardware cursor + plane, when a DRM cursor op fails at runtime, or when a client is drawing its + own cursor surface. Headless always uses the software cursor. +- [x] Software cursor shape changes are wired: the software cursor rebuilds its + buffer whenever the desired named shape changes (default/resize cursors). +- [x] Client-provided Wayland cursor images are honored: a focused client's + `wl_pointer.set_cursor` surface is presented as a software cursor at the + client-supplied hotspot, and the compositor's named cursor takes back over + when the pointer leaves the client's content. - [ ] Native cursor handling uses legacy DRM cursor calls plus a one-time atomic cursor-plane clear. Future atomic cursor-plane ownership should preserve the same high-level policy: hardware cursor first, software cursor fallback, and Xcursor assets as the art source. +- [ ] Client cursor surfaces are always presented in software (composited into + the scene). Uploading a client cursor buffer into the DRM cursor plane could + keep the low-latency hardware path for client cursors too. -- 2.51.2