From b37522bbb3f740b4993226147fd8f376b44b0fc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 15:20:53 +0000 Subject: [PATCH] fix(linux): map pen barrel buttons before egui-winit drops them ## Summary On Linux (Wayland and X11), tablet stylus side buttons arrive as winit MouseButton::Other (BTN_STYLUS and neighbours). egui-winit ignores those codes, so barrel presses never reached the canvas. Run the desktop app through a thin winit ApplicationHandler wrapper on Linux that remaps barrel buttons to right and middle mouse buttons before eframe handles the event. ## Tests cargo test -p vectorcraft linux_stylus cargo clippy -p vectorcraft --all-targets -- -D warnings Closes storytold/vectorcraft#3632 Co-authored-by: nrq6993-a11y --- apps/vectorcraft/src/linux_stylus.rs | 118 +++++++++++++++++++++++++++ apps/vectorcraft/src/main.rs | 15 +++- 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 apps/vectorcraft/src/linux_stylus.rs diff --git a/apps/vectorcraft/src/linux_stylus.rs b/apps/vectorcraft/src/linux_stylus.rs new file mode 100644 index 000000000..0fb47f6d1 --- /dev/null +++ b/apps/vectorcraft/src/linux_stylus.rs @@ -0,0 +1,118 @@ +//! Linux pen tablets: winit reports barrel buttons as [`winit::event::MouseButton::Other`] +//! (for example `BTN_STYLUS`), and egui-winit drops those before they reach the UI (#3632). + +use eframe::AppCreator; +use winit::application::ApplicationHandler; +use winit::event::{DeviceEvent, DeviceId, StartCause, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::window::WindowId; + +use eframe::UserEvent; + +/// `BTN_STYLUS` and neighbours (`linux/input-event-codes.h`): pen barrel buttons Wayland passes +/// through the pointer protocol as non-standard mouse buttons. +const BTN_STYLUS: u16 = 0x014b; +const BTN_STYLUS2: u16 = 0x014c; +const BTN_STYLUS3: u16 = 0x014d; + +/// Like [`eframe::run_native`], but turns pen barrel buttons into mouse buttons egui understands. +pub fn run_native(app_name: &str, mut native_options: eframe::NativeOptions, app_creator: AppCreator<'_>) -> eframe::Result { + let mut builder = EventLoop::::with_user_event(); + if let Some(hook) = native_options.event_loop_builder.take() { + hook(&mut builder); + } + let event_loop = builder.build().map_err(eframe::Error::WinitEventLoop)?; + event_loop.set_control_flow(ControlFlow::Poll); + let inner = eframe::create_native(app_name, native_options, app_creator, &event_loop); + let mut app = Forwarder { inner }; + event_loop.run_app(&mut app).map_err(eframe::Error::WinitEventLoop)?; + Ok(()) +} + +struct Forwarder<'a> { + inner: eframe::EframeWinitApplication<'a>, +} + +impl ApplicationHandler for Forwarder<'_> { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.inner.resumed(event_loop); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) { + self.inner.window_event(event_loop, window_id, map_window_event(event)); + } + + fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) { + self.inner.new_events(event_loop, cause); + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + self.inner.user_event(event_loop, event); + } + + fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: DeviceId, event: DeviceEvent) { + self.inner.device_event(event_loop, device_id, event); + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + self.inner.about_to_wait(event_loop); + } + + fn suspended(&mut self, event_loop: &ActiveEventLoop) { + self.inner.suspended(event_loop); + } + + fn exiting(&mut self, event_loop: &ActiveEventLoop) { + self.inner.exiting(event_loop); + } + + fn memory_warning(&mut self, event_loop: &ActiveEventLoop) { + self.inner.memory_warning(event_loop); + } +} + +/// Map a tablet button code winit labels `MouseButton::Other` to a standard mouse button. +pub(crate) fn map_tablet_button(code: u16) -> Option { + use winit::event::MouseButton; + match code { + BTN_STYLUS | BTN_STYLUS2 => Some(MouseButton::Right), + BTN_STYLUS3 => Some(MouseButton::Middle), + // XInput2 on X11 often numbers the first barrel button 10 or 11. + 10 | 11 => Some(MouseButton::Right), + _ => None, + } +} + +fn map_window_event(event: WindowEvent) -> WindowEvent { + if let WindowEvent::MouseInput { device_id, state, button: winit::event::MouseButton::Other(code), .. } = event + && let Some(button) = map_tablet_button(code) + { + WindowEvent::MouseInput { device_id, state, button } + } else { + event + } +} + +#[cfg(test)] +mod tests { + use super::*; + use winit::event::{ElementState, MouseButton}; + + #[test] + fn stylus_barrel_buttons_become_secondary_clicks() { + assert_eq!(map_tablet_button(BTN_STYLUS), Some(MouseButton::Right)); + assert_eq!(map_tablet_button(BTN_STYLUS2), Some(MouseButton::Right)); + assert_eq!(map_tablet_button(BTN_STYLUS3), Some(MouseButton::Middle)); + assert_eq!(map_tablet_button(0x110), None); + } + + #[test] + fn window_mouse_input_is_remapped_before_eframe() { + let device_id = winit::event::DeviceId::dummy(); + let press = WindowEvent::MouseInput { device_id, state: ElementState::Pressed, button: MouseButton::Other(BTN_STYLUS) }; + let mapped = map_window_event(press); + assert!(matches!(mapped, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Pressed, .. })); + let release = map_window_event(WindowEvent::MouseInput { device_id, state: ElementState::Released, button: MouseButton::Other(BTN_STYLUS2) }); + assert!(matches!(release, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Released, .. })); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 854bc7ef2..e6f3c0c87 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -30,6 +30,8 @@ mod control_server; mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; +#[cfg(target_os = "linux")] +mod linux_stylus; mod logging; #[cfg(target_os = "macos")] mod mac_fonts; @@ -952,9 +954,18 @@ fn main() -> std::process::ExitCode { #[cfg(feature = "wgpu")] let created = startup.clone(); // A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash. + let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| { + #[cfg(target_os = "linux")] + { + linux_stylus::run_native("VectorCraft", options, app_creator) + } + #[cfg(not(target_os = "linux"))] + { + eframe::run_native("VectorCraft", options, app_creator) + } + }; let outcome = vectorcraft_engine::guard::catch_panic(|| { - eframe::run_native( - "VectorCraft", + run( options, Box::new(move |cc| { let mut session = Session::new();