atagen

monolith

public
Star0
Watch0

Commit 0b89f53f59e5

tarball

atagen <boss@atagen.co>2026-10-19 08:38 UTC

5 files changed+213-52
cursor: size theme images to the output scale

The pointer was a fixed 24 pixel image whatever the output, tiny on a scaled panel. Theme shapes are now picked nearest to the cursor size times the scale and stretched to exactly that many pixels, so ImageElement stays unscaled and eligible for the cursor plane. Client cursor surfaces and drag icons are told the scale of the output the pointer is on, and again when it crosses to another.
Modified file: crates/monolith/src/cursor.rs+199 additions−50 deletions
@@ -2,30 +2,38 @@
22 //! cursor surface wins, otherwise the xcursor theme's default arrow.
33
44 use crate::scene::{Draw, to_physical};
5-use monolith_render_vk::VulkanRenderer;
5+use monolith_render_vk::{VulkanFrame, VulkanRenderer, VulkanTexture};
66 use smithay::backend::allocator::Fourcc;
7-use smithay::backend::renderer::element::memory::{
8- MemoryRenderBuffer, MemoryRenderBufferRenderElement,
9-};
7+use smithay::backend::renderer::element::memory::MemoryBuffer;
108 use smithay::backend::renderer::element::solid::SolidColorRenderElement;
119 use smithay::backend::renderer::element::surface::render_elements_from_surface_tree;
12-use smithay::backend::renderer::element::{Id, Kind};
10+use smithay::backend::renderer::element::{Element, Id, Kind, RenderElement, UnderlyingStorage};
1311 use smithay::backend::renderer::utils::CommitCounter;
12+use smithay::backend::renderer::{Frame, ImportMem};
1413 use smithay::input::pointer::CursorIcon;
1514 use smithay::input::pointer::{CursorImageAttributes, CursorImageStatus};
1615 use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
17-use smithay::utils::{Logical, Physical, Point, Rectangle, Transform};
16+use smithay::utils::user_data::UserDataMap;
17+use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Transform};
1818 use smithay::wayland::compositor::with_states;
1919 use std::collections::HashMap;
2020 use std::sync::Mutex;
2121
22-type Image = Option<(MemoryRenderBuffer, Point<i32, Logical>)>;
22+/// A theme shape sized for one output scale.
23+struct Prepared {
24+ id: Id,
25+ pixels: MemoryBuffer,
26+ texture: VulkanTexture,
27+ hotspot: Point<i32, Physical>,
28+}
2329
2430 pub struct Cursor {
2531 theme: xcursor::CursorTheme,
2632 size: u32,
27- /// Shapes loaded so far, including ones the theme lacks.
28- images: HashMap<CursorIcon, Image>,
33+ /// Every image of the shapes read so far, including ones the theme lacks.
34+ files: HashMap<CursorIcon, Option<Vec<xcursor::parser::Image>>>,
35+ /// Shapes sized for an output scale, in steps of 1/120.
36+ sized: HashMap<(CursorIcon, u32), Option<Prepared>>,
2937 }
3038
3139 impl Cursor {
@@ -38,9 +46,10 @@ impl Cursor {
3846 let mut cursor = Self {
3947 theme: xcursor::CursorTheme::load(&theme),
4048 size,
41- images: HashMap::new(),
49+ files: HashMap::new(),
50+ sized: HashMap::new(),
4251 };
43- if cursor.image(CursorIcon::Default).is_none() {
52+ if cursor.file(CursorIcon::Default).is_none() {
4453 tracing::warn!(theme, "no xcursor theme found, drawing a plain cursor");
4554 }
4655 // Reading a shape from the theme takes milliseconds, which in a frame
@@ -64,21 +73,47 @@ impl Cursor {
6473 CursorIcon::SwResize,
6574 CursorIcon::SeResize,
6675 ] {
67- cursor.image(icon);
76+ cursor.file(icon);
6877 }
6978 cursor
7079 }
7180
72- /// The theme's image for `icon`, falling back to the default arrow.
73- fn image(&mut self, icon: CursorIcon) -> Option<&(MemoryRenderBuffer, Point<i32, Logical>)> {
74- if !self.images.contains_key(&icon) {
81+ /// The theme's images for `icon`, falling back to the default arrow.
82+ fn file(&mut self, icon: CursorIcon) -> Option<&[xcursor::parser::Image]> {
83+ if !self.files.contains_key(&icon) {
7584 let names = std::iter::once(icon.name()).chain(icon.alt_names().iter().copied());
76- let image = names
85+ let images = names
7786 .chain(["default", "left_ptr"])
78- .find_map(|n| load_image(&self.theme, n, self.size));
79- self.images.insert(icon, image);
87+ .find_map(|n| load_file(&self.theme, n));
88+ self.files.insert(icon, images);
89+ }
90+ self.files.get(&icon)?.as_deref()
91+ }
92+
93+ /// The theme's image nearest the cursor size times `scale`, stretched to
94+ /// exactly that size so it covers whole pixels at any scale.
95+ fn sized(
96+ &mut self,
97+ renderer: &mut VulkanRenderer,
98+ icon: CursorIcon,
99+ scale: f64,
100+ ) -> Option<&Prepared> {
101+ let key = (icon, (scale * 120.0).round() as u32);
102+ if !self.sized.contains_key(&key) {
103+ let wanted = ((f64::from(self.size) * scale).round() as u32).max(1);
104+ let prepared = self
105+ .file(icon)
106+ .and_then(|images| images.iter().min_by_key(|i| i.size.abs_diff(wanted)))
107+ .and_then(|image| {
108+ prepare(
109+ renderer,
110+ image,
111+ f64::from(wanted) / f64::from(image.size.max(1)),
112+ )
113+ });
114+ self.sized.insert(key, prepared);
80115 }
81- self.images.get(&icon).and_then(Option::as_ref)
116+ self.sized.get(&key)?.as_ref()
82117 }
83118
84119 /// Elements for the pointer at `pos`, in output-local coordinates.
@@ -114,44 +149,158 @@ impl Cursor {
114149 .map(Draw::Surface)
115150 .collect()
116151 }
117- CursorImageStatus::Named(icon) => match self.image(*icon) {
118- Some((buffer, hotspot)) => MemoryRenderBufferRenderElement::from_buffer(
119- renderer,
120- at(*hotspot).to_f64(),
121- buffer,
122- None,
123- None,
124- None,
125- Kind::Cursor,
126- )
127- .map(|e| vec![Draw::Memory(e)])
128- .unwrap_or_default(),
129- None => vec![Draw::Border(SolidColorRenderElement::new(
130- Id::new(),
131- Rectangle::new(at(Point::default()), (8, 8).into()),
132- CommitCounter::default(),
133- [1.0, 1.0, 1.0, 1.0],
134- Kind::Cursor,
135- ))],
136- },
152+ CursorImageStatus::Named(icon) => {
153+ let tip = to_physical((pos.x, pos.y), scale);
154+ match self.sized(renderer, *icon, scale) {
155+ Some(image) => vec![Draw::Cursor(ImageElement::new(image, tip))],
156+ None => {
157+ let side = (8.0 * scale).round() as i32;
158+ vec![Draw::Border(SolidColorRenderElement::new(
159+ Id::new(),
160+ Rectangle::new(tip, (side, side).into()),
161+ CommitCounter::default(),
162+ [1.0, 1.0, 1.0, 1.0],
163+ Kind::Cursor,
164+ ))]
165+ }
166+ }
167+ }
137168 }
138169 }
139170 }
140171
141-fn load_image(theme: &xcursor::CursorTheme, name: &str, size: u32) -> Image {
172+fn load_file(theme: &xcursor::CursorTheme, name: &str) -> Option<Vec<xcursor::parser::Image>> {
142173 let path = theme.load_icon(name)?;
143174 let bytes = std::fs::read(path).ok()?;
144- let images = xcursor::parser::parse_xcursor(&bytes)?;
145- let image = images.iter().min_by_key(|i| i.size.abs_diff(size))?;
146- let buffer = MemoryRenderBuffer::from_slice(
147- &image.pixels_rgba,
148- Fourcc::Argb8888,
149- (image.width as i32, image.height as i32),
150- 1,
151- Transform::Normal,
152- None,
175+ xcursor::parser::parse_xcursor(&bytes)
176+}
177+
178+/// Uploads `image` stretched by `ratio`. The theme stores premultiplied BGRA
179+/// bytes, which is what Argb8888 means on little endian.
180+fn prepare(
181+ renderer: &mut VulkanRenderer,
182+ image: &xcursor::parser::Image,
183+ ratio: f64,
184+) -> Option<Prepared> {
185+ let (w, h) = (image.width as i32, image.height as i32);
186+ let size = (
187+ ((f64::from(w) * ratio).round() as i32).max(1),
188+ ((f64::from(h) * ratio).round() as i32).max(1),
153189 );
154- Some((buffer, (image.xhot as i32, image.yhot as i32).into()))
190+ let pixels = if (w, h) == size {
191+ image.pixels_rgba.clone()
192+ } else {
193+ stretch(&image.pixels_rgba, (w, h), size)
194+ };
195+ let texture = renderer
196+ .import_memory(&pixels, Fourcc::Argb8888, size.into(), false)
197+ .ok()?;
198+ Some(Prepared {
199+ id: Id::new(),
200+ pixels: MemoryBuffer::from_slice(&pixels, Fourcc::Argb8888, size),
201+ texture,
202+ hotspot: (
203+ (f64::from(image.xhot) * ratio).round() as i32,
204+ (f64::from(image.yhot) * ratio).round() as i32,
205+ )
206+ .into(),
207+ })
208+}
209+
210+/// Bilinear resampling of 4 byte pixels, which averages evenly when halving.
211+fn stretch(src: &[u8], (sw, sh): (i32, i32), (dw, dh): (i32, i32)) -> Vec<u8> {
212+ let (fx, fy) = (f64::from(sw) / f64::from(dw), f64::from(sh) / f64::from(dh));
213+ let at = |x: i32, y: i32, c: usize| {
214+ let (x, y) = (x.clamp(0, sw - 1), y.clamp(0, sh - 1));
215+ f64::from(src[(y * sw + x) as usize * 4 + c])
216+ };
217+ let mut out = Vec::with_capacity((dw * dh * 4) as usize);
218+ for y in 0..dh {
219+ let sy = (f64::from(y) + 0.5) * fy - 0.5;
220+ let y0 = sy.floor();
221+ let ty = sy - y0;
222+ for x in 0..dw {
223+ let sx = (f64::from(x) + 0.5) * fx - 0.5;
224+ let x0 = sx.floor();
225+ let tx = sx - x0;
226+ for c in 0..4 {
227+ let (x0, y0) = (x0 as i32, y0 as i32);
228+ let top = at(x0, y0, c) * (1.0 - tx) + at(x0 + 1, y0, c) * tx;
229+ let bottom = at(x0, y0 + 1, c) * (1.0 - tx) + at(x0 + 1, y0 + 1, c) * tx;
230+ out.push((top * (1.0 - ty) + bottom * ty).round() as u8);
231+ }
232+ }
233+ }
234+ out
235+}
236+
237+/// A theme image at whole pixels, drawn unscaled so the cursor plane can
238+/// take it.
239+pub struct ImageElement {
240+ id: Id,
241+ pixels: MemoryBuffer,
242+ texture: VulkanTexture,
243+ geometry: Rectangle<i32, Physical>,
244+}
245+
246+impl ImageElement {
247+ fn new(image: &Prepared, tip: Point<i32, Physical>) -> Self {
248+ let size = image.pixels.size();
249+ Self {
250+ id: image.id.clone(),
251+ pixels: image.pixels.clone(),
252+ texture: image.texture.clone(),
253+ geometry: Rectangle::new(tip - image.hotspot, (size.w, size.h).into()),
254+ }
255+ }
256+}
257+
258+impl Element for ImageElement {
259+ fn id(&self) -> &Id {
260+ &self.id
261+ }
262+
263+ fn current_commit(&self) -> CommitCounter {
264+ CommitCounter::default()
265+ }
266+
267+ fn src(&self) -> Rectangle<f64, Buffer> {
268+ Rectangle::from_size(self.pixels.size().to_f64())
269+ }
270+
271+ fn geometry(&self, _scale: Scale<f64>) -> Rectangle<i32, Physical> {
272+ self.geometry
273+ }
274+
275+ fn kind(&self) -> Kind {
276+ Kind::Cursor
277+ }
278+}
279+
280+impl RenderElement<VulkanRenderer> for ImageElement {
281+ fn draw(
282+ &self,
283+ frame: &mut VulkanFrame<'_, '_>,
284+ src: Rectangle<f64, Buffer>,
285+ dst: Rectangle<i32, Physical>,
286+ damage: &[Rectangle<i32, Physical>],
287+ opaque_regions: &[Rectangle<i32, Physical>],
288+ _cache: Option<&UserDataMap>,
289+ ) -> Result<(), monolith_render_vk::Error> {
290+ frame.render_texture_from_to(
291+ &self.texture,
292+ src,
293+ dst,
294+ damage,
295+ opaque_regions,
296+ Transform::Normal,
297+ 1.0,
298+ )
299+ }
300+
301+ fn underlying_storage(&self, _renderer: &mut VulkanRenderer) -> Option<UnderlyingStorage<'_>> {
302+ Some(UnderlyingStorage::Memory(&self.pixels))
303+ }
155304 }
156305
157306 /// The surface a drag carries under the pointer. `offset` accumulates the
Modified file: crates/monolith/src/input.rs+1 additions
@@ -16,6 +16,7 @@ use smithay::xwayland::xwm::ResizeEdge;
1616 impl State {
1717 pub fn pointer_to(&mut self, pos: Point<f64, Logical>, time: InputTime) {
1818 self.input.pointer_pos = pos;
19+ self.follow_pointer_scale();
1920 self.activity();
2021 if drag_to(self, pos) {
2122 return;
Modified file: crates/monolith/src/scale.rs+9 additions
@@ -102,6 +102,15 @@ impl State {
102102 }
103103 }
104104
105+ /// Retells the cursor and drag icon when the pointer crosses to an output.
106+ pub fn follow_pointer_scale(&mut self) {
107+ let name = self.pointer_output().map(|o| o.name());
108+ if self.input.pointer_output != name {
109+ self.input.pointer_output = name;
110+ self.send_pointer_scales();
111+ }
112+ }
113+
105114 pub fn pointer_output(&self) -> Option<Output> {
106115 self.wm
107116 .space
Modified file: crates/monolith/src/scene.rs+1 additions−2 deletions
@@ -5,7 +5,6 @@ use crate::wm::{LastFrame, Wm};
55 use monolith_config::Decoration;
66 use monolith_layout::{OutputId, Rect, WindowId};
77 use monolith_render_vk::{Clip, Effect, VulkanFrame, VulkanRenderer, VulkanTexture};
8-use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
98 use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
109 use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
1110 use smithay::backend::renderer::element::surface::render_elements_from_surface_tree;
@@ -27,7 +26,7 @@ smithay::backend::renderer::element::render_elements! {
2726 Cropped=CropRenderElement<WaylandSurfaceRenderElement<VulkanRenderer>>,
2827 Texture=PlacedTexture,
2928 Border=SolidColorRenderElement,
30- Memory=MemoryRenderBufferRenderElement<VulkanRenderer>,
29+ Cursor=crate::cursor::ImageElement,
3130 Rescaled=RescaleRenderElement<WaylandSurfaceRenderElement<VulkanRenderer>>,
3231 Clipped=ClipElement,
3332 }
Modified file: crates/monolith/src/state.rs+3 additions
@@ -63,6 +63,8 @@ pub struct Input {
6363 pub dnd_icon: Option<crate::cursor::DndIcon>,
6464 /// The edges of the window border under the pointer, for the resize cursor.
6565 pub hover: Option<crate::input::Edges>,
66+ /// The output the pointer was last on, whose scale cursor and drag icon surfaces follow.
67+ pub pointer_output: Option<String>,
6668 }
6769
6870 /// What needs doing after this batch of events.
@@ -195,6 +197,7 @@ impl State {
195197 cursor_status: CursorImageStatus::default_named(),
196198 dnd_icon: None,
197199 hover: None,
200+ pointer_output: None,
198201 },
199202 dirty: Dirty {
200203 redraw: true,