From df884cb623083a9e2360f467bce192ffe3e0e429 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 00:53:48 +0100 Subject: [PATCH] feat: render named cursor images --- Cargo.lock | 1 + Cargo.toml | 1 + compositor/Cargo.toml | 1 + compositor/src/cursor.rs | 146 +++++++++++++++++++++++++++++++++ compositor/src/handlers/mod.rs | 5 ++ compositor/src/main.rs | 1 + compositor/src/state.rs | 12 ++- compositor/src/winit.rs | 103 ++++++++++++++++------- 8 files changed, 239 insertions(+), 31 deletions(-) create mode 100644 compositor/src/cursor.rs diff --git a/Cargo.lock b/Cargo.lock index 2d93cfc..9315c10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,7 @@ dependencies = [ "smithay", "tracing", "tracing-subscriber", + "xcursor", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 27d42d2..7e03daf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ smithay = { version = "0.7.0", default-features = false, features = [ ] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +xcursor = "0.3.11" diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 4d91b2c..e7cb163 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -13,3 +13,4 @@ libc.workspace = true smithay.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +xcursor.workspace = true diff --git a/compositor/src/cursor.rs b/compositor/src/cursor.rs new file mode 100644 index 0000000..5007f0d --- /dev/null +++ b/compositor/src/cursor.rs @@ -0,0 +1,146 @@ +use std::{fs, sync::OnceLock}; + +use smithay::{ + backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer}, + input::pointer::CursorIcon, + utils::{Buffer, Point, Size, Transform}, +}; +use xcursor::{CursorTheme, parser::Image}; + +const DEFAULT_CURSOR_SIZE: u32 = 24; + +#[derive(Debug, Clone)] +pub(crate) struct NamedCursor { + pub buffer: MemoryRenderBuffer, + pub hotspot: Point, +} + +pub(crate) fn cursor_theme() -> CursorTheme { + let name = std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into()); + + CursorTheme::load(&name) +} + +pub(crate) fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon) -> NamedCursor { + let requested_size = cursor_size(); + let image = cursor_names(icon) + .iter() + .find_map(|name| load_image(theme, name, requested_size)); + + match image { + Some(image) => cursor_from_image(image), + None => fallback_cursor(), + } +} + +fn cursor_size() -> u32 { + static SIZE: OnceLock = OnceLock::new(); + + *SIZE.get_or_init(|| { + std::env::var("XCURSOR_SIZE") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|size| *size > 0) + .unwrap_or(DEFAULT_CURSOR_SIZE) + }) +} + +fn cursor_names(icon: CursorIcon) -> [&'static str; 3] { + match icon { + CursorIcon::Default => ["default", "left_ptr", "arrow"], + CursorIcon::Pointer => ["pointer", "hand2", "left_ptr"], + CursorIcon::Text => ["text", "xterm", "left_ptr"], + _ => [icon.name(), "default", "left_ptr"], + } +} + +fn load_image(theme: &CursorTheme, name: &str, requested_size: u32) -> Option { + let path = theme.load_icon(name)?; + let bytes = fs::read(path).ok()?; + + xcursor::parser::parse_xcursor(&bytes)? + .into_iter() + .min_by_key(|image| { + ( + image.size.abs_diff(requested_size), + image.width.abs_diff(requested_size), + ) + }) +} + +fn cursor_from_image(image: Image) -> NamedCursor { + let size = Size::::from((image.width as i32, image.height as i32)); + let buffer = MemoryRenderBuffer::from_slice( + &image.pixels_rgba, + Fourcc::Abgr8888, + size, + 1, + Transform::Normal, + None, + ); + + NamedCursor { + buffer, + hotspot: Point::from((image.xhot as i32, image.yhot as i32)), + } +} + +fn fallback_cursor() -> NamedCursor { + const WIDTH: usize = 12; + const HEIGHT: usize = 18; + let mut pixels = vec![0_u8; WIDTH * HEIGHT * 4]; + + for y in 0..HEIGHT { + let body_width = (y / 2 + 1).min(8); + + for x in 0..body_width { + let edge = x == 0 || x + 1 == body_width || y == 0; + let offset = (y * WIDTH + x) * 4; + let color = if edge { 0 } else { 255 }; + pixels[offset..offset + 4].copy_from_slice(&[color, color, color, 255]); + } + } + + NamedCursor { + buffer: MemoryRenderBuffer::from_slice( + &pixels, + Fourcc::Abgr8888, + Size::::from((WIDTH as i32, HEIGHT as i32)), + 1, + Transform::Normal, + None, + ), + hotspot: Point::default(), + } +} + +#[cfg(test)] +mod tests { + use smithay::input::pointer::CursorIcon; + + use super::cursor_names; + + #[test] + fn supplies_legacy_fallback_names_for_common_cursors() { + assert_eq!( + cursor_names(CursorIcon::Default), + ["default", "left_ptr", "arrow"] + ); + assert_eq!( + cursor_names(CursorIcon::Pointer), + ["pointer", "hand2", "left_ptr"] + ); + assert_eq!( + cursor_names(CursorIcon::Text), + ["text", "xterm", "left_ptr"] + ); + } + + #[test] + fn falls_back_to_default_for_other_named_cursors() { + assert_eq!( + cursor_names(CursorIcon::Crosshair), + ["crosshair", "default", "left_ptr"] + ); + } +} diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 2732a3c..2222edd 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -45,6 +45,11 @@ impl SeatHandler for Ferese { } fn cursor_image(&mut self, _seat: &Seat, image: CursorImageStatus) { + if let CursorImageStatus::Named(icon) = &image { + self.named_cursors + .entry(*icon) + .or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon)); + } self.cursor_status = image; crate::backends::direct::render_all(self); } diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 7bcd600..af4ec5e 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -1,6 +1,7 @@ #![allow(irrefutable_let_patterns)] mod backends; +mod cursor; mod grabs; mod handlers; mod input; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 43c0cb3..84f83f0 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -16,7 +16,10 @@ use smithay::{ LayerSurface, PopupManager, Space, Window, WindowSurfaceType, layer_map_for_output, utils::send_frames_surface_tree, }, - input::{Seat, SeatState, pointer::CursorImageStatus}, + input::{ + Seat, SeatState, + pointer::{CursorIcon, CursorImageStatus}, + }, output::Output, reexports::{ calloop::{EventLoop, Interest, LoopSignal, Mode, PostAction, generic::Generic}, @@ -62,6 +65,8 @@ pub struct Ferese { pub window_geometry: HashMap, pub focused_window: Option, pub cursor_status: CursorImageStatus, + pub(crate) cursor_theme: xcursor::CursorTheme, + pub(crate) named_cursors: HashMap, pub intercepted_keys: HashSet, pub idle_inhibitors: HashMap, pub direct_backend: Option, @@ -122,6 +127,9 @@ impl Ferese { let socket_name = Self::init_wayland_listener(display, event_loop)?; let start_time = Instant::now(); + let cursor_theme = crate::cursor::cursor_theme(); + let default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default); + let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]); Ok(Self { start_time, @@ -134,6 +142,8 @@ impl Ferese { window_geometry: HashMap::new(), focused_window: None, cursor_status: CursorImageStatus::default_named(), + cursor_theme, + named_cursors, intercepted_keys: HashSet::new(), idle_inhibitors: HashMap::new(), direct_backend: None, diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 845491f..5ef900c 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -9,6 +9,8 @@ use smithay::{ damage::OutputDamageTracker, element::{ AsRenderElements, Kind as RenderElementKind, + memory::MemoryRenderBufferRenderElement, + render_elements, surface::{WaylandSurfaceRenderElement, render_elements_from_surface_tree}, utils::{ ConstrainAlign, ConstrainScaleBehavior, CropRenderElement, Relocate, @@ -35,10 +37,20 @@ use smithay::{ use crate::Ferese; -pub(crate) type AnimatedWindowRenderElement = CropRenderElement< +type SurfaceRenderElement = CropRenderElement< RelocateRenderElement>>, >; +type MemoryRenderElement = CropRenderElement< + RelocateRenderElement>>, +>; + +render_elements! { + pub(crate) AnimatedWindowRenderElement<=GlesRenderer>; + Surface=SurfaceRenderElement, + Memory=MemoryRenderElement, +} + pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<(), Box> { let (mut backend, event_source) = winit::init()?; let initial_scale = normalized_scale(backend.scale_factor()); @@ -281,7 +293,7 @@ fn layer_elements( let element = RescaleRenderElement::from_element(element, origin, 1.0); let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); - CropRenderElement::from_element(element, scale, output_crop) + CropRenderElement::from_element(element, scale, output_crop).map(Into::into) }) .collect() } @@ -292,42 +304,73 @@ fn cursor_elements( output_geometry: Rectangle, scale: f64, ) -> Vec { - let CursorImageStatus::Surface(surface) = &state.cursor_status else { - return Vec::new(); - }; let Some(pointer) = state.seat.get_pointer() else { return Vec::new(); }; - let hotspot = with_states(surface, |states| { - states - .data_map - .get::() - .map(|attributes| attributes.lock().unwrap().hotspot) - .unwrap_or_default() - }); - let logical_location = - pointer.current_location() - output_geometry.loc.to_f64() - hotspot.to_f64(); - let physical_location = logical_location.to_physical_precise_round(scale); let output_crop = Rectangle::::from_size(output_geometry.size).to_physical_precise_round(scale); let origin = Point::::default(); + let pointer_location = pointer.current_location() - output_geometry.loc.to_f64(); + match &state.cursor_status { + CursorImageStatus::Surface(surface) => { + let hotspot = with_states(surface, |states| { + states + .data_map + .get::() + .map(|attributes| attributes.lock().unwrap().hotspot) + .unwrap_or_default() + }); + let physical_location = + (pointer_location - hotspot.to_f64()).to_physical_precise_round(scale); - render_elements_from_surface_tree::>( - renderer, - surface, - physical_location, - scale, - 1.0, - RenderElementKind::Cursor, - ) - .into_iter() - .filter_map(|element| { - let element = RescaleRenderElement::from_element(element, origin, 1.0); - let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); + render_elements_from_surface_tree::< + GlesRenderer, + WaylandSurfaceRenderElement, + >( + renderer, + surface, + physical_location, + scale, + 1.0, + RenderElementKind::Cursor, + ) + .into_iter() + .filter_map(|element| { + let element = RescaleRenderElement::from_element(element, origin, 1.0); + let element = + RelocateRenderElement::from_element(element, origin, Relocate::Relative); - CropRenderElement::from_element(element, scale, output_crop) - }) - .collect() + CropRenderElement::from_element(element, scale, output_crop).map(Into::into) + }) + .collect() + } + CursorImageStatus::Named(icon) => { + let Some(cursor) = state.named_cursors.get(icon) else { + return Vec::new(); + }; + let hotspot = Point::::from((cursor.hotspot.x, cursor.hotspot.y)); + let physical_location = pointer_location.to_physical_precise_round(scale) - hotspot; + let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( + renderer, + physical_location.to_f64(), + &cursor.buffer, + None, + None, + None, + RenderElementKind::Cursor, + ) else { + return Vec::new(); + }; + + let element = RescaleRenderElement::from_element(element, origin, 1.0); + let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); + + CropRenderElement::from_element(element, scale, output_crop) + .map(|element| vec![element.into()]) + .unwrap_or_default() + } + CursorImageStatus::Hidden => Vec::new(), + } } fn normalized_scale(scale: f64) -> f64 {