Add drawing tablet input support
This commit is contained in:
parent
f72c2c5dbd
commit
b8f847e167
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue