Skip to main content

max / shop

Show an I-beam over the grid SCTK's ThemedPointer is both routes at once: wp_cursor_shape_v1 where the compositor has it, the XCursor theme where it does not. Neither had to be picked, and the fallback costs a wl_shm bind and a cursor surface. Set per Enter rather than once, since the shape is attached to an enter serial and the compositor resets the pointer on every crossing. The whole window gets it, padding included, because cell_at already treats padding as the nearest cell.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 15:28 UTC
Signed with PGP, not checked
Commit: 5d8e20926ec3f1d48b44ac04e0f72a11f20de8a8
Parent: 0186058
1 file changed, +74 insertions, -8 deletions
@@ -45,7 +45,10 @@
45 45 },
46 46 registry_handlers,
47 47 seat::keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},
48 - seat::pointer::{PointerEvent, PointerEventKind, PointerHandler},
48 + seat::pointer::{
49 + CursorIcon, PointerEvent, PointerEventKind, PointerHandler, ThemeSpec, ThemedPointer,
50 + },
51 + shm::{Shm, ShmHandler},
49 52 };
50 53 use std::collections::VecDeque;
51 54 use std::io::{Read, Write};
@@ -243,6 +246,14 @@
243 246 .inspect_err(|e| warn!("no primary selection, middle-click paste disabled: {e}"))
244 247 .ok();
245 248
249 + // Only the XCursor half of the themed pointer reads from it, and only on
250 + // compositors without `wp_cursor_shape_v1`. shop paints with the GPU, so
251 + // this is the one shm buffer in the process and losing it costs the
252 + // I-beam, not the terminal.
253 + let shm = Shm::bind(&globals, &qh)
254 + .inspect_err(|e| warn!("no wl_shm, pointer falls back to the compositor default: {e}"))
255 + .ok();
256 +
246 257 // The loop is built before the app so the app can hold a handle to it.
247 258 let mut event_loop: EventLoop<'static, App> = EventLoop::try_new()?;
248 259 let loop_handle = event_loop.handle();
@@ -267,6 +278,7 @@
267 278 scale: 1,
268 279 keyboard: None,
269 280 pointer: None,
281 + shm,
270 282 modifiers: Modifiers::default(),
271 283 pointer_at: (0.0, 0.0),
272 284 selection: None,
@@ -700,7 +712,13 @@
700 712 /// HiDPI integer scale from `wl_surface.enter` outputs.
701 713 scale: u32,
702 714 keyboard: Option<wl_keyboard::WlKeyboard>,
703 - pointer: Option<wl_pointer::WlPointer>,
715 + /// Themed rather than plain so the pointer can be an I-beam over the grid.
716 + /// SCTK sends `wp_cursor_shape_v1` where the compositor has it and loads
717 + /// the XCursor theme where it does not, so shop picks neither route.
718 + pointer: Option<ThemedPointer>,
719 + /// Only the XCursor fallback needs it, so a compositor without `wl_shm`
720 + /// costs the fallback and nothing else.
721 + shm: Option<Shm>,
704 722 modifiers: Modifiers,
705 723 /// Pointer position in surface-local logical pixels, from the last event
706 724 /// that carried one. Every event does, so this is always current.
@@ -1032,6 +1050,15 @@
1032 1050 fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
1033 1051 }
1034 1052
1053 + impl ShmHandler for App {
1054 + fn shm_state(&mut self) -> &mut Shm {
1055 + // Unreachable without a binding: SCTK routes wl_shm events to the
1056 + // wl_shm `Shm::bind` created, so if the bind failed there is no object
1057 + // for an event to arrive on.
1058 + self.shm.as_mut().expect("wl_shm event without a wl_shm")
1059 + }
1060 + }
1061 +
1035 1062 impl SeatHandler for App {
1036 1063 fn seat_state(&mut self) -> &mut SeatState {
1037 1064 &mut self.chrome.seat
@@ -1077,10 +1104,30 @@
1077 1104 Err(e) => warn!("get_keyboard_with_repeat: {e}"),
1078 1105 }
1079 1106 }
1080 - if capability == Capability::Pointer && self.pointer.is_none() {
1081 - match self.chrome.seat.get_pointer(qh, &seat) {
1107 + if capability == Capability::Pointer
1108 + && self.pointer.is_none()
1109 + && let Some(shm) = &self.shm
1110 + {
1111 + // The surface is the cursor's, not the window's: the XCursor
1112 + // fallback attaches its image to it. `wp_cursor_shape_v1` leaves
1113 + // it unused, which is the common case and costs one wl_surface.
1114 + //
1115 + // `ThemeSpec::System` reads XCURSOR_THEME and XCURSOR_SIZE, so a
1116 + // user who themes their pointer keeps their theme rather than
1117 + // getting shop's idea of one.
1118 + let cursor_surface = self.chrome.compositor.create_surface(qh);
1119 + // `S` is the surface's user data, `()` because `create_surface`
1120 + // above makes a plain one; inference has nothing else to go on.
1121 + let themed = self.chrome.seat.get_pointer_with_theme::<Self, ()>(
1122 + qh,
1123 + &seat,
1124 + shm.wl_shm(),
1125 + cursor_surface,
1126 + ThemeSpec::System,
1127 + );
1128 + match themed {
1082 1129 Ok(ptr) => self.pointer = Some(ptr),
1083 - Err(e) => warn!("get_pointer: {e}"),
1130 + Err(e) => warn!("get_pointer_with_theme: {e}"),
1084 1131 }
1085 1132 }
1086 1133 }
@@ -1099,7 +1146,10 @@
1099 1146 if capability == Capability::Pointer
1100 1147 && let Some(ptr) = self.pointer.take()
1101 1148 {
1102 - ptr.release();
1149 + // ThemedPointer's Drop releases the wl_pointer and destroys the
1150 + // shape device and the cursor surface, so taking it is the whole
1151 + // teardown; an explicit release here would be a double release.
1152 + drop(ptr);
1103 1153 // A pointer that goes away mid-drag leaves no way to finish the
1104 1154 // gesture, so end it where it stands rather than latching.
1105 1155 self.dragging = false;
@@ -1387,7 +1437,7 @@
1387 1437 impl PointerHandler for App {
1388 1438 fn pointer_frame(
1389 1439 &mut self,
1390 - _: &Connection,
1440 + conn: &Connection,
1391 1441 _: &QueueHandle<Self>,
1392 1442 _: &wl_pointer::WlPointer,
1393 1443 events: &[PointerEvent],
@@ -1401,7 +1451,23 @@
1401 1451 }
1402 1452 self.pointer_at = event.position;
1403 1453 match event.kind {
1404 - PointerEventKind::Motion { .. } | PointerEventKind::Enter { .. } => {
1454 + PointerEventKind::Enter { .. } => {
1455 + // Per entry, not once at startup: the shape is attached to
1456 + // an enter serial, and the compositor resets the pointer to
1457 + // its own default every time it crosses back in.
1458 + //
1459 + // The whole window gets the I-beam, not only the cells. The
1460 + // padding is dead space that `cell_at` already treats as
1461 + // the nearest cell, so a shape change at its edge would
1462 + // advertise a boundary that selection does not have.
1463 + if let Some(ptr) = &self.pointer
1464 + && let Err(e) = ptr.set_cursor(conn, CursorIcon::Text)
1465 + {
1466 + warn!("set_cursor: {e}");
1467 + }
1468 + self.drag_selection();
1469 + }
1470 + PointerEventKind::Motion { .. } => {
1405 1471 self.drag_selection();
1406 1472 }
1407 1473 PointerEventKind::Press {