From b8f847e1671db7282a761ed4dd333a12290b0545 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 11 Mar 2026 10:58:30 -0400 Subject: [PATCH] Add drawing tablet input support --- lightningbeam-ui/Cargo.lock | 7 +- .../lightningbeam-editor/Cargo.toml | 7 + .../lightningbeam-editor/src/main.rs | 16 + .../src/panes/infopanel.rs | 10 + .../lightningbeam-editor/src/panes/stage.rs | 50 +- .../lightningbeam-editor/src/raster_tool.rs | 8 +- .../lightningbeam-editor/src/tablet.rs | 920 ++++++++++++++++++ .../lightningbeam-editor/src/tools/mod.rs | 5 + .../lightningbeam-editor/src/tools/paint.rs | 4 +- 9 files changed, 1005 insertions(+), 22 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tablet.rs diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 7cc9d75..6b92e3d 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -3468,7 +3468,7 @@ dependencies = [ [[package]] name = "lightningbeam-editor" -version = "1.0.2-alpha" +version = "1.0.3-alpha" dependencies = [ "beamdsp", "bytemuck", @@ -3502,8 +3502,13 @@ dependencies = [ "tiny-skia", "uuid", "vello", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-sys", "wgpu", "winit", + "x11rb", ] [[package]] diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index d93bfb0..d15a9cf 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -63,6 +63,13 @@ notify-rust = { workspace = true } # Debug overlay - memory tracking memory-stats = "1.1" +[target.'cfg(target_os = "linux")'.dependencies] +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client"] } +wayland-backend = { version = "0.3", features = ["client_system"] } +wayland-sys = { version = "0.31", features = ["client"] } +x11rb = { version = "0.13", features = ["xinput"] } + [package.metadata.deb] name = "lightningbeam-editor" maintainer = "Skyler" diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index c34c9d4..f8bf384 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -48,6 +48,7 @@ mod effect_thumbnails; use effect_thumbnails::EffectThumbnailGenerator; mod custom_cursor; +mod tablet; mod debug_overlay; #[cfg(debug_assertions)] @@ -926,6 +927,8 @@ struct EditorApp { /// Custom cursor cache for SVG cursors cursor_cache: custom_cursor::CursorCache, + /// Cross-platform graphics tablet (pen/stylus) input state + tablet: tablet::TabletInput, /// Debug test mode (F5) — input recording, panic capture & visual replay #[cfg(debug_assertions)] test_mode: test_mode::TestModeState, @@ -1165,6 +1168,7 @@ impl EditorApp { // Debug overlay (F3) cursor_cache: custom_cursor::CursorCache::new(), + tablet: tablet::TabletInput::new(cc), debug_overlay_visible: false, debug_stats_collector: debug_overlay::DebugStatsCollector::new(), gpu_info, @@ -4674,9 +4678,21 @@ impl EditorApp { } impl eframe::App for EditorApp { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.tablet.poll(ctx, raw_input, self.selected_tool); + + // Pressure and tilt are now in the global AtomicU32 cells (tablet::current_pressure / + // current_tilt) and will be picked up by make_stroke_point on the next stroke tick. + } + fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let _frame_start = std::time::Instant::now(); + // Consume any pending tool switch from the tablet (eraser in/out). + if let Some(tool) = self.tablet.pending_tool_switch.take() { + self.selected_tool = tool; + } + // Force continuous repaint if we have pending waveform updates // This ensures thumbnails update immediately when waveform data arrives if !self.audio_pools_with_new_waveforms.is_empty() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 1d3829d..924db25 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -612,6 +612,16 @@ impl InfopanelPane { .logarithmic(true) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); }); + + let bs = if is_eraser { &rs.active_eraser_settings } else { &rs.active_brush_settings }; + if bs.elliptical_dab_ratio > 1.001 { + ui.horizontal(|ui| { + ui.label("Angle:"); + ui.add(egui::Slider::new(&mut rs.brush_angle_offset, -180.0_f32..=180.0) + .suffix("°") + .custom_formatter(|v, _| format!("{:.0}°", v))); + }); + } } /// Render the brush preset thumbnail grid (collapsible). diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index f91085f..614b058 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -5756,17 +5756,19 @@ impl StagePane { if w == 0 || h == 0 { return; } let mut float_pixels = vec![0u8; (w * h * 4) as usize]; - for row in 0..h { - let sy = y0 + row as i32; - if sy < 0 || sy >= kf.height as i32 { continue; } - for col in 0..w { - let sx = x0 + col as i32; - if sx < 0 || sx >= kf.width as i32 { continue; } - if !sel.contains_pixel(sx, sy) { continue; } - let si = ((sy as u32 * kf.width + sx as u32) * 4) as usize; - let di = ((row * w + col) * 4) as usize; - float_pixels[di..di + 4].copy_from_slice(&kf.raw_pixels[si..si + 4]); - kf.raw_pixels[si..si + 4].fill(0); + if !kf.raw_pixels.is_empty() { + for row in 0..h { + let sy = y0 + row as i32; + if sy < 0 || sy >= kf.height as i32 { continue; } + for col in 0..w { + let sx = x0 + col as i32; + if sx < 0 || sx >= kf.width as i32 { continue; } + if !sel.contains_pixel(sx, sy) { continue; } + let si = ((sy as u32 * kf.width + sx as u32) * 4) as usize; + let di = ((row * w + col) * 4) as usize; + float_pixels[di..di + 4].copy_from_slice(&kf.raw_pixels[si..si + 4]); + kf.raw_pixels[si..si + 4].fill(0); + } } } @@ -5919,10 +5921,13 @@ impl StagePane { // Compute first dab (same arithmetic as the layer case). let mut stroke_state = StrokeState::new(); // Convert to float-local space: dabs must be in canvas pixel coords. + let (tilt_x, tilt_y) = crate::tablet::current_tilt(); let first_pt = StrokePoint { x: world_pos.x - float_x as f32, y: world_pos.y - float_y as f32, - pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + pressure: crate::tablet::current_pressure(), + tilt_x, tilt_y, + timestamp: 0.0, }; let single = StrokeRecord { brush_settings: brush.clone(), @@ -6009,9 +6014,12 @@ impl StagePane { // Compute the first dab (single-point tap) let mut stroke_state = StrokeState::new(); + let (tilt_x, tilt_y) = crate::tablet::current_tilt(); let first_pt = StrokePoint { x: world_pos.x, y: world_pos.y, - pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + pressure: crate::tablet::current_pressure(), + tilt_x, tilt_y, + timestamp: 0.0, }; let single = StrokeRecord { brush_settings: brush.clone(), @@ -6083,9 +6091,12 @@ impl StagePane { }; // Convert current world position to canvas-local space. + let (tilt_x, tilt_y) = crate::tablet::current_tilt(); let curr_local = StrokePoint { x: world_pos.x - cx, y: world_pos.y - cy, - pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + pressure: crate::tablet::current_pressure(), + tilt_x, tilt_y, + timestamp: 0.0, }; const MIN_DIST_SQ: f32 = 1.5 * 1.5; @@ -6156,10 +6167,13 @@ impl StagePane { }; if let Some((canvas_id, cw, ch, cx, cy)) = canvas_info { + let (tilt_x, tilt_y) = crate::tablet::current_tilt(); let pt = StrokePoint { x: world_pos.x - cx, y: world_pos.y - cy, - pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + pressure: crate::tablet::current_pressure(), + tilt_x, tilt_y, + timestamp: 0.0, }; let single = StrokeRecord { brush_settings: brush.clone(), @@ -11081,7 +11095,8 @@ impl StagePane { let bs = &shared.raster_settings.active_brush_settings; let ratio = bs.elliptical_dab_ratio.max(1.0); let expand = 1.0 + bs.offset_by_random; - (r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians()) + let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); + (r * expand, r * expand / ratio, angle) } else { (r, r, 0.0_f32) } @@ -11090,7 +11105,8 @@ impl StagePane { let r = shared.raster_settings.brush_radius; let ratio = bs.elliptical_dab_ratio.max(1.0); let expand = 1.0 + bs.offset_by_random; - (r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians()) + let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); + (r * expand, r * expand / ratio, angle) }; let a = a_world * self.zoom; // major semi-axis in screen pixels diff --git a/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs index f99dac3..d15dad2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs @@ -299,12 +299,14 @@ impl BrushRasterTool { } fn make_stroke_point(pos: egui::Vec2, off_x: i32, off_y: i32) -> StrokePoint { + let pressure = crate::tablet::current_pressure(); + let (tilt_x, tilt_y) = crate::tablet::current_tilt(); StrokePoint { x: pos.x - off_x as f32, y: pos.y - off_y as f32, - pressure: 1.0, - tilt_x: 0.0, - tilt_y: 0.0, + pressure, + tilt_x, + tilt_y, timestamp: 0.0, } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tablet.rs b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs new file mode 100644 index 0000000..e39a5e5 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs @@ -0,0 +1,920 @@ +/// Cross-platform graphics tablet input support. +/// +/// Architecture: +/// - Wayland: secondary event queue + background thread using `zwp_tablet_manager_v2` +/// - X11: x11rb XInput2 raw events for pressure/tilt (cursor already works via OS mouse emulation) +/// - Windows: pressure read from egui `Event::Touch` (winit already converts WM_POINTER) +/// - macOS: cursor/clicks work via mouse emulation; pressure/tilt is future work +/// +/// Pressure and tilt are stored in AtomicU32 globals so `make_stroke_point` can read them +/// without needing a context parameter or trait changes. + +use std::sync::atomic::{AtomicU32, Ordering}; +use eframe::egui; +use lightningbeam_core::tool::Tool; + +// --------------------------------------------------------------------------- +// Global tablet state — read by make_stroke_point() on the UI thread +// --------------------------------------------------------------------------- + +static TABLET_PRESSURE_BITS: AtomicU32 = AtomicU32::new(0x3f800000); // 1.0f32 +static TABLET_TILT_X_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 +static TABLET_TILT_Y_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 + +/// Current pen pressure (0.0–1.0). Falls back to 1.0 when no tablet is active. +pub fn current_pressure() -> f32 { + f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed)) +} + +/// Current pen tilt in radians (tilt_x, tilt_y). +pub fn current_tilt() -> (f32, f32) { + let x = f32::from_bits(TABLET_TILT_X_BITS.load(Ordering::Relaxed)); + let y = f32::from_bits(TABLET_TILT_Y_BITS.load(Ordering::Relaxed)); + (x, y) +} + +fn set_pressure(p: f32) { + TABLET_PRESSURE_BITS.store(p.to_bits(), Ordering::Relaxed); +} + +fn set_tilt(x: f32, y: f32) { + TABLET_TILT_X_BITS.store(x.to_bits(), Ordering::Relaxed); + TABLET_TILT_Y_BITS.store(y.to_bits(), Ordering::Relaxed); +} + +// --------------------------------------------------------------------------- +// Shared event types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub enum RawTabletEvent { + ProximityIn { tool_type: TabletToolType }, + ProximityOut, + /// Physical pixel coords relative to window surface (Wayland). + /// On X11 this is unused (cursor comes via OS). + Motion { x: f64, y: f64 }, + Pressure(f32), + Tilt { x: f32, y: f32 }, + TipDown, + TipUp, + /// End of Wayland tablet event group; commit accumulated state. + Frame, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum TabletToolType { + #[default] + Pen, + Eraser, + Other, +} + +// --------------------------------------------------------------------------- +// TabletInput — owned by EditorApp +// --------------------------------------------------------------------------- + +pub struct TabletInput { + /// Latest tool position in egui logical pixels relative to the window. + /// Only set on Wayland (X11/Windows/macOS: cursor already follows OS pointer). + pub position: Option, + /// Last known position, persists across frames so PointerButton has a valid position + /// even when TipDown arrives in a frame without a Motion event. + last_known_pos: egui::Pos2, + pub pressure: f32, + pub tilt: (f32, f32), + /// `Some(true)` = tip went down this frame; `Some(false)` = tip went up; `None` = no change. + pub tip_down: Option, + pub in_proximity: bool, + pub was_in_proximity: bool, + pub tool_type: TabletToolType, + /// True when the Wayland backend is active (needs PointerButton injection). + /// On X11, clicks already come through winit via OS mouse emulation. + inject_buttons: bool, + + /// Pending tool switch (eraser in/out). Consumed by `EditorApp::update()`. + pub pending_tool_switch: Option, + tool_before_eraser: Option, + + /// One-shot sender used to hand the egui Context to the background thread + /// on the first poll() call so it can call request_repaint(). + repaint_sender: Option>, + + backend: TabletBackend, +} + +enum TabletBackend { + #[cfg(target_os = "linux")] + Wayland(std::sync::mpsc::Receiver), + #[cfg(target_os = "linux")] + X11(std::sync::mpsc::Receiver), + None, +} + +impl TabletInput { + pub fn new(cc: &eframe::CreationContext) -> Self { + let (backend, inject_buttons, repaint_sender) = Self::init_backend(cc); + TabletInput { + position: None, + last_known_pos: egui::Pos2::ZERO, + pressure: 1.0, + tilt: (0.0, 0.0), + tip_down: None, + in_proximity: false, + was_in_proximity: false, + tool_type: TabletToolType::Pen, + inject_buttons, + pending_tool_switch: None, + tool_before_eraser: None, + repaint_sender, + backend, + } + } + + #[cfg(target_os = "linux")] + fn init_backend( + cc: &eframe::CreationContext, + ) -> (TabletBackend, bool, Option>) { + use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; + let raw = cc.display_handle().ok().map(|h| h.as_raw()); + match raw { + Some(RawDisplayHandle::Wayland(h)) => { + let (repaint_tx, repaint_rx) = std::sync::mpsc::channel::(); + match wayland::init(h, repaint_rx) { + // Wayland: winit sees no tablet events at all, inject everything. + Some(rx) => (TabletBackend::Wayland(rx), true, Some(repaint_tx)), + None => (TabletBackend::None, false, None), + } + } + Some(RawDisplayHandle::Xlib(h)) => { + match x11::init_xlib(h) { + // X11: OS mouse emulation already sends clicks through winit. + Some(rx) => (TabletBackend::X11(rx), false, None), + None => (TabletBackend::None, false, None), + } + } + Some(RawDisplayHandle::Xcb(h)) => { + match x11::init_xcb(h) { + Some(rx) => (TabletBackend::X11(rx), false, None), + None => (TabletBackend::None, false, None), + } + } + _ => (TabletBackend::None, false, None), + } + } + + #[cfg(not(target_os = "linux"))] + fn init_backend( + _cc: &eframe::CreationContext, + ) -> (TabletBackend, bool, Option>) { + (TabletBackend::None, false, None) + } + + /// Call from `raw_input_hook`. Drains platform events and updates state. + /// `current_tool` is needed to save the tool before switching to the eraser. + pub fn poll( + &mut self, + ctx: &egui::Context, + raw_input: &mut egui::RawInput, + current_tool: Tool, + ) { + // On the first poll() we have the egui Context available. Hand it to the + // background thread so it can call request_repaint() and wake the event loop. + if let Some(tx) = self.repaint_sender.take() { + // send() is non-blocking on an unbounded channel. + let _ = tx.send(ctx.clone()); + } + + self.was_in_proximity = self.in_proximity; + self.tip_down = None; + self.position = None; + + // Windows: read pressure from egui Touch events (winit converts WM_POINTER). + #[cfg(target_os = "windows")] + self.poll_windows(ctx); + + // Linux: drain the platform event channel. + #[cfg(target_os = "linux")] + self.poll_linux(ctx); + + // Inject synthetic egui events on Wayland (X11/Windows cursor moves via OS). + self.inject_events(raw_input); + + // Handle eraser tool switch. + self.handle_tool_switch(current_tool); + + // Publish globals for make_stroke_point(). + set_pressure(self.pressure); + let (tx, ty) = self.tilt; + set_tilt(tx, ty); + } + + #[cfg(target_os = "windows")] + fn poll_windows(&mut self, ctx: &egui::Context) { + let pressure = ctx.input(|i| { + i.events.iter().rev().find_map(|e| { + if let egui::Event::Touch { + force: Some(egui::TouchForce::Normalized(f)), + .. + } = e + { + Some(*f) + } else { + None + } + }) + }); + if let Some(p) = pressure { + self.pressure = p; + if !self.in_proximity { + self.in_proximity = true; + } + } + } + + #[cfg(target_os = "linux")] + fn poll_linux(&mut self, ctx: &egui::Context) { + use std::sync::mpsc::TryRecvError; + + let pixels_per_point = ctx.pixels_per_point(); + + let rx = match &self.backend { + TabletBackend::Wayland(rx) => rx as *const std::sync::mpsc::Receiver, + TabletBackend::X11(rx) => rx as *const std::sync::mpsc::Receiver, + TabletBackend::None => return, + }; + // SAFETY: we own self exclusively; the raw pointer is valid for this call duration. + let rx = unsafe { &*rx }; + + loop { + match rx.try_recv() { + Ok(event) => self.apply_event(event, pixels_per_point), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + // Background thread exited; degrade gracefully. + self.backend = TabletBackend::None; + break; + } + } + } + } + + fn apply_event(&mut self, event: RawTabletEvent, pixels_per_point: f32) { + match event { + RawTabletEvent::ProximityIn { tool_type } => { + self.tool_type = tool_type; + self.in_proximity = true; + } + RawTabletEvent::ProximityOut => { + self.in_proximity = false; + self.pressure = 0.0; + } + RawTabletEvent::Motion { x, y } => { + // Wayland gives physical pixels; convert to egui logical points. + let lx = x as f32 / pixels_per_point; + let ly = y as f32 / pixels_per_point; + let pos = egui::pos2(lx, ly); + self.position = Some(pos); + self.last_known_pos = pos; + } + RawTabletEvent::Pressure(p) => { + self.pressure = p; + } + RawTabletEvent::Tilt { x, y } => { + // Convert degrees to radians for StrokePoint. + self.tilt = (x.to_radians(), y.to_radians()); + } + RawTabletEvent::TipDown => { + self.tip_down = Some(true); + } + RawTabletEvent::TipUp => { + self.tip_down = Some(false); + } + RawTabletEvent::Frame => { + // Frame is the commit signal; processing already happened in individual events. + } + } + } + + fn inject_events(&self, raw_input: &mut egui::RawInput) { + if let Some(pos) = self.position { + raw_input.events.push(egui::Event::PointerMoved(pos)); + } + // Only inject button events on Wayland. On X11 the OS mouse emulation already + // sends clicks through winit; injecting here would cause duplicate events. + if self.inject_buttons { + if let Some(pressed) = self.tip_down { + // Use last_known_pos so clicks work even when TipDown arrives without + // a simultaneous Motion event (pen stationary when touching). + raw_input.events.push(egui::Event::PointerButton { + pos: self.last_known_pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: raw_input.modifiers, + }); + } + } + if !self.in_proximity && self.was_in_proximity { + raw_input.events.push(egui::Event::PointerGone); + } + } + + fn handle_tool_switch(&mut self, current_tool: Tool) { + let just_entered = self.in_proximity && !self.was_in_proximity; + let just_left = !self.in_proximity && self.was_in_proximity; + + if just_entered && self.tool_type == TabletToolType::Eraser { + // Save the current tool so we can restore it later. + if current_tool != Tool::Erase { + self.tool_before_eraser = Some(current_tool); + } + self.pending_tool_switch = Some(Tool::Erase); + } else if just_left && self.tool_type == TabletToolType::Eraser { + // Restore previous tool. + if let Some(prev) = self.tool_before_eraser.take() { + self.pending_tool_switch = Some(prev); + } + } + } +} + +// --------------------------------------------------------------------------- +// Wayland backend +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +mod wayland { + use super::{RawTabletEvent, TabletToolType}; + use eframe::egui; + use std::collections::HashMap; + use std::sync::mpsc; + use winit::raw_window_handle::WaylandDisplayHandle; + + use wayland_client::{ + globals::registry_queue_init, + protocol::{wl_registry, wl_seat}, + Connection, Dispatch, QueueHandle, delegate_noop, event_created_child, + }; + use wayland_backend::sys::client::Backend; + use wayland_protocols::wp::tablet::zv2::client::{ + zwp_tablet_manager_v2, zwp_tablet_seat_v2, zwp_tablet_tool_v2, + zwp_tablet_v2, zwp_tablet_pad_v2, zwp_tablet_pad_ring_v2, + zwp_tablet_pad_strip_v2, zwp_tablet_pad_group_v2, + }; + + /// Attempt to connect to the Wayland tablet protocol. + /// Returns `None` if the compositor doesn't support `zwp_tablet_manager_v2`. + /// `repaint_rx` receives the egui Context from the UI thread on its first poll(), + /// after which the background thread calls `ctx.request_repaint()` on every tablet frame + /// so the event loop wakes up and drains the channel. + pub fn init( + handle: WaylandDisplayHandle, + repaint_rx: mpsc::Receiver, + ) -> Option> { + let (tx, rx) = mpsc::channel(); + + let ptr = handle.display.as_ptr() as *mut wayland_sys::client::wl_display; + // SAFETY: `handle.display` is a valid `wl_display *` owned by winit. + let backend = unsafe { Backend::from_foreign_display(ptr) }; + let conn = Connection::from_backend(backend); + + let (globals, mut event_queue) = match registry_queue_init::(&conn) { + Ok(result) => result, + Err(_) => return None, + }; + + let qh = event_queue.handle(); + + // Bind wl_seat (required to obtain a zwp_tablet_seat_v2). + let seat: wl_seat::WlSeat = match globals.bind(&qh, 1..=8, ()) { + Ok(s) => s, + Err(_) => return None, + }; + + // Bind the tablet manager. + let mgr: zwp_tablet_manager_v2::ZwpTabletManagerV2 = match globals.bind(&qh, 1..=1, ()) { + Ok(m) => m, + Err(_) => return None, + }; + + // Create the tablet seat, which will start delivering tool_added events. + let _tablet_seat = mgr.get_tablet_seat(&seat, &qh, ()); + + // Initial roundtrip to discover pre-existing tools. + let mut dispatch = TabletDispatch { + tx: tx.clone(), + tools: HashMap::new(), + }; + + if event_queue.roundtrip(&mut dispatch).is_err() { + return None; + } + + // Spawn background thread; owns the event queue. + std::thread::Builder::new() + .name("lightningbeam-tablet-wayland".into()) + .spawn(move || { + // Wait for the UI thread to hand us the egui Context (happens on first poll()). + let repaint_ctx: Option = repaint_rx.recv().ok(); + + loop { + if event_queue.blocking_dispatch(&mut dispatch).is_err() { + break; + } + // Wake the egui event loop so raw_input_hook runs and drains our channel. + if let Some(ref ctx) = repaint_ctx { + ctx.request_repaint(); + } + } + }) + .ok()?; + + Some(rx) + } + + // ----------------------------------------------------------------------- + // Per-tool accumulator + // ----------------------------------------------------------------------- + + #[derive(Default)] + struct ToolAccumulator { + tool_type: TabletToolType, + pending_x: f64, + pending_y: f64, + pending_pressure: f32, + pending_tilt: (f32, f32), + pending_motion: bool, + pending_tip: Option, + pending_proximity: Option, + } + + // ----------------------------------------------------------------------- + // Dispatch state + // ----------------------------------------------------------------------- + + struct TabletDispatch { + tx: mpsc::Sender, + tools: HashMap, + } + + // --- wl_registry (required by registry_queue_init) --- + impl Dispatch + for TabletDispatch + { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &wayland_client::globals::GlobalListContents, + _conn: &Connection, + _qh: &QueueHandle, + ) { + } + } + + delegate_noop!(TabletDispatch: ignore wl_seat::WlSeat); + delegate_noop!(TabletDispatch: ignore zwp_tablet_manager_v2::ZwpTabletManagerV2); + delegate_noop!(TabletDispatch: ignore zwp_tablet_v2::ZwpTabletV2); + delegate_noop!(TabletDispatch: ignore zwp_tablet_pad_ring_v2::ZwpTabletPadRingV2); + delegate_noop!(TabletDispatch: ignore zwp_tablet_pad_strip_v2::ZwpTabletPadStripV2); + + // --- zwp_tablet_seat_v2: receives tool_added, tablet_added, pad_added --- + impl Dispatch for TabletDispatch { + fn event( + state: &mut Self, + _proxy: &zwp_tablet_seat_v2::ZwpTabletSeatV2, + event: zwp_tablet_seat_v2::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + match event { + zwp_tablet_seat_v2::Event::ToolAdded { id } => { + state.tools.entry(id).or_default(); + } + zwp_tablet_seat_v2::Event::TabletAdded { .. } => {} + zwp_tablet_seat_v2::Event::PadAdded { .. } => {} + _ => {} + } + } + + event_created_child!(TabletDispatch, zwp_tablet_seat_v2::ZwpTabletSeatV2, [ + zwp_tablet_seat_v2::EVT_TABLET_ADDED_OPCODE => (zwp_tablet_v2::ZwpTabletV2, ()), + zwp_tablet_seat_v2::EVT_TOOL_ADDED_OPCODE => (zwp_tablet_tool_v2::ZwpTabletToolV2, ()), + zwp_tablet_seat_v2::EVT_PAD_ADDED_OPCODE => (zwp_tablet_pad_v2::ZwpTabletPadV2, ()), + ]); + } + + // --- zwp_tablet_pad_v2: announces groups via new-id 'group' event --- + impl Dispatch for TabletDispatch { + fn event( + _state: &mut Self, + _proxy: &zwp_tablet_pad_v2::ZwpTabletPadV2, + _event: zwp_tablet_pad_v2::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + } + + event_created_child!(TabletDispatch, zwp_tablet_pad_v2::ZwpTabletPadV2, [ + zwp_tablet_pad_v2::EVT_GROUP_OPCODE => (zwp_tablet_pad_group_v2::ZwpTabletPadGroupV2, ()), + ]); + } + + // --- zwp_tablet_pad_group_v2: announces rings and strips via new-id events --- + impl Dispatch for TabletDispatch { + fn event( + _state: &mut Self, + _proxy: &zwp_tablet_pad_group_v2::ZwpTabletPadGroupV2, + _event: zwp_tablet_pad_group_v2::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + } + + event_created_child!(TabletDispatch, zwp_tablet_pad_group_v2::ZwpTabletPadGroupV2, [ + zwp_tablet_pad_group_v2::EVT_RING_OPCODE => (zwp_tablet_pad_ring_v2::ZwpTabletPadRingV2, ()), + zwp_tablet_pad_group_v2::EVT_STRIP_OPCODE => (zwp_tablet_pad_strip_v2::ZwpTabletPadStripV2, ()), + ]); + } + + // --- zwp_tablet_tool_v2: the core tablet event stream --- + impl Dispatch for TabletDispatch { + fn event( + state: &mut Self, + proxy: &zwp_tablet_tool_v2::ZwpTabletToolV2, + event: zwp_tablet_tool_v2::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + use zwp_tablet_tool_v2::Event; + + let acc = state.tools.entry(proxy.clone()).or_default(); + + match event { + Event::Type { tool_type } => { + acc.tool_type = match tool_type.into_result() { + Ok(zwp_tablet_tool_v2::Type::Pen) => TabletToolType::Pen, + Ok(zwp_tablet_tool_v2::Type::Eraser) => TabletToolType::Eraser, + _ => TabletToolType::Other, + }; + } + Event::ProximityIn { .. } => { + acc.pending_proximity = Some(true); + } + Event::ProximityOut => { + acc.pending_proximity = Some(false); + } + Event::Down { .. } => { + acc.pending_tip = Some(true); + } + Event::Up => { + acc.pending_tip = Some(false); + } + Event::Motion { x, y } => { + // wayland-client already decodes wl_fixed_t to f64 (physical pixels). + acc.pending_x = x; + acc.pending_y = y; + acc.pending_motion = true; + } + Event::Pressure { pressure } => { + acc.pending_pressure = pressure as f32 / 65535.0; + } + Event::Tilt { tilt_x, tilt_y } => { + acc.pending_tilt = (tilt_x as f32, tilt_y as f32); + } + Event::Frame { .. } => { + // Flush accumulated events to the channel. + if let Some(prox) = acc.pending_proximity.take() { + if prox { + let _ = state.tx.send(RawTabletEvent::ProximityIn { + tool_type: acc.tool_type, + }); + } else { + let _ = state.tx.send(RawTabletEvent::ProximityOut); + } + } + if acc.pending_motion { + let _ = state.tx.send(RawTabletEvent::Motion { + x: acc.pending_x, + y: acc.pending_y, + }); + let _ = state.tx.send(RawTabletEvent::Pressure(acc.pending_pressure)); + let (tx, ty) = acc.pending_tilt; + let _ = state.tx.send(RawTabletEvent::Tilt { x: tx, y: ty }); + acc.pending_motion = false; + } + if let Some(tip) = acc.pending_tip.take() { + let _ = state.tx.send(if tip { + RawTabletEvent::TipDown + } else { + RawTabletEvent::TipUp + }); + } + let _ = state.tx.send(RawTabletEvent::Frame); + } + Event::Removed => { + state.tools.remove(proxy); + } + _ => {} + } + } + } +} + +// --------------------------------------------------------------------------- +// X11 backend — XInput2 for pressure + tilt +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +mod x11 { + use super::{RawTabletEvent, TabletToolType}; + use std::sync::mpsc; + use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle}; + + /// Init from an Xlib display handle. Opens a second X11 connection for XI2 raw events. + pub fn init_xlib(_handle: XlibDisplayHandle) -> Option> { + // Both Xlib and XCB paths create a fresh independent connection (X11 allows N connections). + init_inner() + } + + /// Init from an XCB display handle. + pub fn init_xcb(_handle: XcbDisplayHandle) -> Option> { + init_inner() + } + + fn init_inner() -> Option> { + use x11rb::connection::Connection; + use x11rb::protocol::xinput; + use x11rb::protocol::xinput::ConnectionExt as XInputExt; + use x11rb::rust_connection::RustConnection; + + let (tx, rx) = mpsc::channel(); + + // Open a dedicated connection for XI2 event listening. + let (conn, screen_num) = match RustConnection::connect(None) { + Ok(c) => c, + Err(_) => return None, + }; + + // Check XI2 availability. + match conn.xinput_xi_query_version(2, 2) { + Ok(reply) => { + if reply.reply().map(|r| r.major_version < 2).unwrap_or(true) { + return None; + } + } + Err(_) => return None, + } + + let root = conn.setup().roots[screen_num].root; + + // Discover stylus/eraser devices and their pressure/tilt axes. + let device_axes = match discover_devices(&conn) { + Ok(d) => d, + Err(_) => return None, + }; + + if device_axes.is_empty() { + return None; + } + + // Select XI2 raw motion + button events on the root window. + let masks: Vec = device_axes + .keys() + .map(|&devid| xinput::EventMask { + deviceid: devid, + mask: vec![ + (xinput::XIEventMask::RAW_MOTION + | xinput::XIEventMask::RAW_BUTTON_PRESS + | xinput::XIEventMask::RAW_BUTTON_RELEASE) + .into(), + ], + }) + .collect(); + + if conn.xinput_xi_select_events(root, &masks).is_err() { + return None; + } + let _ = conn.flush(); + + std::thread::Builder::new() + .name("lightningbeam-tablet-x11".into()) + .spawn(move || { + event_loop(conn, device_axes, tx); + }) + .ok()?; + + Some(rx) + } + + // ----------------------------------------------------------------------- + // Per-device axis mapping + // ----------------------------------------------------------------------- + + #[derive(Debug, Clone)] + struct DeviceAxes { + pressure_axis: Option, + tilt_x_axis: Option, + tilt_y_axis: Option, + tool_type: TabletToolType, + /// Range of pressure axis (min, max) for normalisation. + pressure_range: (f64, f64), + tilt_range: (f64, f64), + } + + fn discover_devices( + conn: &x11rb::rust_connection::RustConnection, + ) -> Result, x11rb::errors::ReplyError> { + use x11rb::protocol::xinput; + use x11rb::protocol::xinput::ConnectionExt as XInputExt; + use x11rb::protocol::xproto::ConnectionExt as XprotoExt; + #[allow(unused_imports)] + use x11rb::connection::Connection; + + let mut result = std::collections::HashMap::new(); + + // 0 = XIAllDevices + let devices = conn + .xinput_xi_query_device(0u16)? + .reply()?; + + // Intern atoms we need. + let atom_pressure = conn.intern_atom(false, b"Abs Pressure")?.reply()?.atom; + let atom_tilt_x = conn.intern_atom(false, b"Abs Tilt X")?.reply()?.atom; + let atom_tilt_y = conn.intern_atom(false, b"Abs Tilt Y")?.reply()?.atom; + + for dev in &devices.infos { + let name = std::str::from_utf8(&dev.name).unwrap_or("").to_lowercase(); + let is_stylus = name.contains("stylus") || name.contains("pen"); + let is_eraser = name.contains("eraser"); + + if !is_stylus && !is_eraser { + continue; + } + + let mut pressure_axis = None; + let mut tilt_x_axis = None; + let mut tilt_y_axis = None; + let mut pressure_range = (0.0_f64, 65535.0_f64); + let mut tilt_range = (-64.0_f64, 63.0_f64); + + for (idx, class) in dev.classes.iter().enumerate() { + if let xinput::DeviceClassData::Valuator(v) = &class.data { + if v.label == atom_pressure { + pressure_axis = Some(idx); + pressure_range = (v.min.integral as f64, v.max.integral as f64); + } else if v.label == atom_tilt_x { + tilt_x_axis = Some(idx); + tilt_range = (v.min.integral as f64, v.max.integral as f64); + } else if v.label == atom_tilt_y { + tilt_y_axis = Some(idx); + } + } + } + + if pressure_axis.is_some() || tilt_x_axis.is_some() { + result.insert( + dev.deviceid, + DeviceAxes { + pressure_axis, + tilt_x_axis, + tilt_y_axis, + tool_type: if is_eraser { + TabletToolType::Eraser + } else { + TabletToolType::Pen + }, + pressure_range, + tilt_range, + }, + ); + } + } + + Ok(result) + } + + // ----------------------------------------------------------------------- + // Event loop (background thread) + // ----------------------------------------------------------------------- + + fn event_loop( + conn: x11rb::rust_connection::RustConnection, + device_axes: std::collections::HashMap, + tx: mpsc::Sender, + ) { + use x11rb::connection::Connection; + #[allow(unused_imports)] + use x11rb::protocol::xinput; + use x11rb::protocol::Event; + + // Track which devices are in proximity. + let mut in_proximity: std::collections::HashSet = std::collections::HashSet::new(); + + loop { + let event = match conn.wait_for_event() { + Ok(e) => e, + Err(_) => break, + }; + + match event { + Event::XinputRawMotion(raw) => { + let devid = raw.deviceid; + let axes = match device_axes.get(&devid) { + Some(a) => a, + None => continue, + }; + + // Synthesise proximity in when we first see motion from a device. + if !in_proximity.contains(&devid) { + in_proximity.insert(devid); + let _ = tx.send(RawTabletEvent::ProximityIn { + tool_type: axes.tool_type, + }); + } + + let valuators = &raw.axisvalues; + + // Pressure + if let Some(idx) = axes.pressure_axis { + if let Some(v) = valuators.get(idx) { + let norm = normalize( + v.integral as f64 + v.frac as f64 / 65536.0, + axes.pressure_range.0, + axes.pressure_range.1, + ); + let _ = tx.send(RawTabletEvent::Pressure(norm as f32)); + } + } + + // Tilt + let tx_deg = axes.tilt_x_axis.and_then(|i| valuators.get(i)).map(|v| { + map_range( + v.integral as f64 + v.frac as f64 / 65536.0, + axes.tilt_range.0, + axes.tilt_range.1, + -90.0, + 90.0, + ) as f32 + }); + let ty_deg = axes.tilt_y_axis.and_then(|i| valuators.get(i)).map(|v| { + map_range( + v.integral as f64 + v.frac as f64 / 65536.0, + axes.tilt_range.0, + axes.tilt_range.1, + -90.0, + 90.0, + ) as f32 + }); + if tx_deg.is_some() || ty_deg.is_some() { + let _ = tx.send(RawTabletEvent::Tilt { + x: tx_deg.unwrap_or(0.0), + y: ty_deg.unwrap_or(0.0), + }); + } + + let _ = tx.send(RawTabletEvent::Frame); + } + + Event::XinputRawButtonPress(raw) => { + if device_axes.contains_key(&raw.deviceid) && raw.detail == 1 { + let _ = tx.send(RawTabletEvent::TipDown); + let _ = tx.send(RawTabletEvent::Frame); + } + } + + Event::XinputRawButtonRelease(raw) => { + if device_axes.contains_key(&raw.deviceid) { + if raw.detail == 1 { + let _ = tx.send(RawTabletEvent::TipUp); + let _ = tx.send(RawTabletEvent::Frame); + } + // When all buttons released, synthesise proximity out. + in_proximity.remove(&raw.deviceid); + let _ = tx.send(RawTabletEvent::ProximityOut); + let _ = tx.send(RawTabletEvent::Frame); + } + } + + _ => {} + } + } + } + + fn normalize(val: f64, min: f64, max: f64) -> f64 { + if (max - min).abs() < 1e-9 { + return 1.0; + } + ((val - min) / (max - min)).clamp(0.0, 1.0) + } + + fn map_range(val: f64, in_min: f64, in_max: f64, out_min: f64, out_max: f64) -> f64 { + if (in_max - in_min).abs() < 1e-9 { + return out_min; + } + (val - in_min) / (in_max - in_min) * (out_max - out_min) + out_min + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index de4c0c9..80efc73 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -109,6 +109,10 @@ pub struct RasterToolSettings { // --- Gradient --- pub gradient: lightningbeam_core::gradient::ShapeGradient, pub gradient_opacity: f32, + // --- Brush rotation offset --- + /// User-controlled angle offset added to the brush's elliptical_dab_angle (degrees). + /// Lets the user re-orient stock .myb brushes without editing the file. + pub brush_angle_offset: f32, } /// Brush mode for the Liquify tool. @@ -208,6 +212,7 @@ impl Default for RasterToolSettings { liquify_strength: 0.5, gradient: lightningbeam_core::gradient::ShapeGradient::default(), gradient_opacity: 1.0, + brush_angle_offset: 0.0, } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs index 75c134e..6d3e192 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs @@ -9,8 +9,10 @@ impl RasterToolDef for PaintTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } fn header_label(&self) -> &'static str { "Brush" } fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + let mut base_settings = s.active_brush_settings.clone(); + base_settings.elliptical_dab_angle += s.brush_angle_offset; BrushParams { - base_settings: s.active_brush_settings.clone(), + base_settings, radius: s.brush_radius, opacity: s.brush_opacity, hardness: s.brush_hardness,