Add drawing tablet input support
This commit is contained in:
parent
f72c2c5dbd
commit
b8f847e167
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -5756,6 +5756,7 @@ impl StagePane {
|
|||
if w == 0 || h == 0 { return; }
|
||||
|
||||
let mut float_pixels = vec![0u8; (w * h * 4) as usize];
|
||||
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; }
|
||||
|
|
@ -5769,6 +5770,7 @@ impl StagePane {
|
|||
kf.raw_pixels[si..si + 4].fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-set selection (commit_raster_floating_now cleared it) and create float.
|
||||
shared.selection.raster_selection = Some(sel);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue