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 <nrq6993-a11y@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-10-23 02:04:47 +07:00
committed by bflatastic
co-authored by nrq6993-a11y
parent 2d85603bcc
commit b37522bbb3
2 changed files with 131 additions and 2 deletions
+118
View File
@@ -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::<UserEvent>::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<UserEvent> 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<winit::event::MouseButton> {
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, .. }));
}
}
+13 -2
View File
@@ -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();