Add drawing tablet input support

This commit is contained in:
Skyler Lehmkuhl 2026-03-11 10:58:30 -04:00
parent f72c2c5dbd
commit b8f847e167
9 changed files with 1005 additions and 22 deletions

View File

@ -3468,7 +3468,7 @@ dependencies = [
[[package]] [[package]]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.2-alpha" version = "1.0.3-alpha"
dependencies = [ dependencies = [
"beamdsp", "beamdsp",
"bytemuck", "bytemuck",
@ -3502,8 +3502,13 @@ dependencies = [
"tiny-skia", "tiny-skia",
"uuid", "uuid",
"vello", "vello",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-sys",
"wgpu", "wgpu",
"winit", "winit",
"x11rb",
] ]
[[package]] [[package]]

View File

@ -63,6 +63,13 @@ notify-rust = { workspace = true }
# Debug overlay - memory tracking # Debug overlay - memory tracking
memory-stats = "1.1" 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] [package.metadata.deb]
name = "lightningbeam-editor" name = "lightningbeam-editor"
maintainer = "Skyler" maintainer = "Skyler"

View File

@ -48,6 +48,7 @@ mod effect_thumbnails;
use effect_thumbnails::EffectThumbnailGenerator; use effect_thumbnails::EffectThumbnailGenerator;
mod custom_cursor; mod custom_cursor;
mod tablet;
mod debug_overlay; mod debug_overlay;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -926,6 +927,8 @@ struct EditorApp {
/// Custom cursor cache for SVG cursors /// Custom cursor cache for SVG cursors
cursor_cache: custom_cursor::CursorCache, 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 /// Debug test mode (F5) — input recording, panic capture & visual replay
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
test_mode: test_mode::TestModeState, test_mode: test_mode::TestModeState,
@ -1165,6 +1168,7 @@ impl EditorApp {
// Debug overlay (F3) // Debug overlay (F3)
cursor_cache: custom_cursor::CursorCache::new(), cursor_cache: custom_cursor::CursorCache::new(),
tablet: tablet::TabletInput::new(cc),
debug_overlay_visible: false, debug_overlay_visible: false,
debug_stats_collector: debug_overlay::DebugStatsCollector::new(), debug_stats_collector: debug_overlay::DebugStatsCollector::new(),
gpu_info, gpu_info,
@ -4674,9 +4678,21 @@ impl EditorApp {
} }
impl eframe::App for 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) { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let _frame_start = std::time::Instant::now(); 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 // Force continuous repaint if we have pending waveform updates
// This ensures thumbnails update immediately when waveform data arrives // This ensures thumbnails update immediately when waveform data arrives
if !self.audio_pools_with_new_waveforms.is_empty() { if !self.audio_pools_with_new_waveforms.is_empty() {

View File

@ -612,6 +612,16 @@ impl InfopanelPane {
.logarithmic(true) .logarithmic(true)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); .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). /// Render the brush preset thumbnail grid (collapsible).

View File

@ -5756,17 +5756,19 @@ impl StagePane {
if w == 0 || h == 0 { return; } if w == 0 || h == 0 { return; }
let mut float_pixels = vec![0u8; (w * h * 4) as usize]; let mut float_pixels = vec![0u8; (w * h * 4) as usize];
for row in 0..h { if !kf.raw_pixels.is_empty() {
let sy = y0 + row as i32; for row in 0..h {
if sy < 0 || sy >= kf.height as i32 { continue; } let sy = y0 + row as i32;
for col in 0..w { if sy < 0 || sy >= kf.height as i32 { continue; }
let sx = x0 + col as i32; for col in 0..w {
if sx < 0 || sx >= kf.width as i32 { continue; } let sx = x0 + col as i32;
if !sel.contains_pixel(sx, sy) { continue; } if sx < 0 || sx >= kf.width as i32 { continue; }
let si = ((sy as u32 * kf.width + sx as u32) * 4) as usize; if !sel.contains_pixel(sx, sy) { continue; }
let di = ((row * w + col) * 4) as usize; let si = ((sy as u32 * kf.width + sx as u32) * 4) as usize;
float_pixels[di..di + 4].copy_from_slice(&kf.raw_pixels[si..si + 4]); let di = ((row * w + col) * 4) as usize;
kf.raw_pixels[si..si + 4].fill(0); 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). // Compute first dab (same arithmetic as the layer case).
let mut stroke_state = StrokeState::new(); let mut stroke_state = StrokeState::new();
// Convert to float-local space: dabs must be in canvas pixel coords. // 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 { let first_pt = StrokePoint {
x: world_pos.x - float_x as f32, x: world_pos.x - float_x as f32,
y: world_pos.y - float_y 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 { let single = StrokeRecord {
brush_settings: brush.clone(), brush_settings: brush.clone(),
@ -6009,9 +6014,12 @@ impl StagePane {
// Compute the first dab (single-point tap) // Compute the first dab (single-point tap)
let mut stroke_state = StrokeState::new(); let mut stroke_state = StrokeState::new();
let (tilt_x, tilt_y) = crate::tablet::current_tilt();
let first_pt = StrokePoint { let first_pt = StrokePoint {
x: world_pos.x, y: world_pos.y, 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 { let single = StrokeRecord {
brush_settings: brush.clone(), brush_settings: brush.clone(),
@ -6083,9 +6091,12 @@ impl StagePane {
}; };
// Convert current world position to canvas-local space. // Convert current world position to canvas-local space.
let (tilt_x, tilt_y) = crate::tablet::current_tilt();
let curr_local = StrokePoint { let curr_local = StrokePoint {
x: world_pos.x - cx, y: world_pos.y - cy, 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; 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 { if let Some((canvas_id, cw, ch, cx, cy)) = canvas_info {
let (tilt_x, tilt_y) = crate::tablet::current_tilt();
let pt = StrokePoint { let pt = StrokePoint {
x: world_pos.x - cx, x: world_pos.x - cx,
y: world_pos.y - cy, 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 { let single = StrokeRecord {
brush_settings: brush.clone(), brush_settings: brush.clone(),
@ -11081,7 +11095,8 @@ impl StagePane {
let bs = &shared.raster_settings.active_brush_settings; let bs = &shared.raster_settings.active_brush_settings;
let ratio = bs.elliptical_dab_ratio.max(1.0); let ratio = bs.elliptical_dab_ratio.max(1.0);
let expand = 1.0 + bs.offset_by_random; 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 { } else {
(r, r, 0.0_f32) (r, r, 0.0_f32)
} }
@ -11090,7 +11105,8 @@ impl StagePane {
let r = shared.raster_settings.brush_radius; let r = shared.raster_settings.brush_radius;
let ratio = bs.elliptical_dab_ratio.max(1.0); let ratio = bs.elliptical_dab_ratio.max(1.0);
let expand = 1.0 + bs.offset_by_random; 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 let a = a_world * self.zoom; // major semi-axis in screen pixels

View File

@ -299,12 +299,14 @@ impl BrushRasterTool {
} }
fn make_stroke_point(pos: egui::Vec2, off_x: i32, off_y: i32) -> StrokePoint { 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 { StrokePoint {
x: pos.x - off_x as f32, x: pos.x - off_x as f32,
y: pos.y - off_y as f32, y: pos.y - off_y as f32,
pressure: 1.0, pressure,
tilt_x: 0.0, tilt_x,
tilt_y: 0.0, tilt_y,
timestamp: 0.0, timestamp: 0.0,
} }
} }

View File

@ -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.01.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<egui::Pos2>,
/// 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<bool>,
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>,
tool_before_eraser: Option<Tool>,
/// 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<std::sync::mpsc::Sender<egui::Context>>,
backend: TabletBackend,
}
enum TabletBackend {
#[cfg(target_os = "linux")]
Wayland(std::sync::mpsc::Receiver<RawTabletEvent>),
#[cfg(target_os = "linux")]
X11(std::sync::mpsc::Receiver<RawTabletEvent>),
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<std::sync::mpsc::Sender<egui::Context>>) {
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::<egui::Context>();
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<std::sync::mpsc::Sender<egui::Context>>) {
(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<RawTabletEvent>,
TabletBackend::X11(rx) => rx as *const std::sync::mpsc::Receiver<RawTabletEvent>,
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<egui::Context>,
) -> Option<mpsc::Receiver<RawTabletEvent>> {
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::<TabletDispatch>(&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<egui::Context> = 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<bool>,
pending_proximity: Option<bool>,
}
// -----------------------------------------------------------------------
// Dispatch state
// -----------------------------------------------------------------------
struct TabletDispatch {
tx: mpsc::Sender<RawTabletEvent>,
tools: HashMap<zwp_tablet_tool_v2::ZwpTabletToolV2, ToolAccumulator>,
}
// --- wl_registry (required by registry_queue_init) ---
impl Dispatch<wl_registry::WlRegistry, wayland_client::globals::GlobalListContents>
for TabletDispatch
{
fn event(
_state: &mut Self,
_proxy: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &wayland_client::globals::GlobalListContents,
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
}
}
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<zwp_tablet_seat_v2::ZwpTabletSeatV2, ()> for TabletDispatch {
fn event(
state: &mut Self,
_proxy: &zwp_tablet_seat_v2::ZwpTabletSeatV2,
event: zwp_tablet_seat_v2::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
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<zwp_tablet_pad_v2::ZwpTabletPadV2, ()> for TabletDispatch {
fn event(
_state: &mut Self,
_proxy: &zwp_tablet_pad_v2::ZwpTabletPadV2,
_event: zwp_tablet_pad_v2::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
}
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<zwp_tablet_pad_group_v2::ZwpTabletPadGroupV2, ()> 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<Self>,
) {
}
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<zwp_tablet_tool_v2::ZwpTabletToolV2, ()> for TabletDispatch {
fn event(
state: &mut Self,
proxy: &zwp_tablet_tool_v2::ZwpTabletToolV2,
event: zwp_tablet_tool_v2::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
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<mpsc::Receiver<RawTabletEvent>> {
// 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<mpsc::Receiver<RawTabletEvent>> {
init_inner()
}
fn init_inner() -> Option<mpsc::Receiver<RawTabletEvent>> {
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<xinput::EventMask> = 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<usize>,
tilt_x_axis: Option<usize>,
tilt_y_axis: Option<usize>,
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<std::collections::HashMap<u16, DeviceAxes>, 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<u16, DeviceAxes>,
tx: mpsc::Sender<RawTabletEvent>,
) {
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<u16> = 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
}
}

View File

@ -109,6 +109,10 @@ pub struct RasterToolSettings {
// --- Gradient --- // --- Gradient ---
pub gradient: lightningbeam_core::gradient::ShapeGradient, pub gradient: lightningbeam_core::gradient::ShapeGradient,
pub gradient_opacity: f32, 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. /// Brush mode for the Liquify tool.
@ -208,6 +212,7 @@ impl Default for RasterToolSettings {
liquify_strength: 0.5, liquify_strength: 0.5,
gradient: lightningbeam_core::gradient::ShapeGradient::default(), gradient: lightningbeam_core::gradient::ShapeGradient::default(),
gradient_opacity: 1.0, gradient_opacity: 1.0,
brush_angle_offset: 0.0,
} }
} }
} }

View File

@ -9,8 +9,10 @@ impl RasterToolDef for PaintTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal }
fn header_label(&self) -> &'static str { "Brush" } fn header_label(&self) -> &'static str { "Brush" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { 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 { BrushParams {
base_settings: s.active_brush_settings.clone(), base_settings,
radius: s.brush_radius, radius: s.brush_radius,
opacity: s.brush_opacity, opacity: s.brush_opacity,
hardness: s.brush_hardness, hardness: s.brush_hardness,