Files
ink-notes/src/pen.rs
T
haraldandClaude Opus 5.5 a8aef30db7 feat(android): pen input, soft keyboard and exports to Downloads (Issues #42, #44)
- Pen vs. finger (#44): the patched winit hands Android pointers with
  tool type stylus/eraser to the app separately (take_pen_samples, with
  hover and button state) instead of as touch events. src/pen.rs turns
  them, like octotablet's events on the desktop, into one PenInput
  stream, so pen buttons, the eraser end and palm rejection work the
  same everywhere. Hover exit right before touch-down does not count as
  leaving; a pen without hover leaves 150 ms after lifting.
- Soft keyboard: shown via AndroidApp::show_soft_input when a text field
  gets focus, hidden when it loses it. The patched winit now fills
  KeyEvent::text, so typed characters reach egui.
- Exports (PDF, PNG, SVG) are written to the app folder and copied to
  Download/ink-notes through the MediaStore (Android 10+, via JNI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:54:48 +01:00

383 lines
13 KiB
Rust

//! Pen input independent of the platform (Issue #44).
//!
//! On the desktop the pen comes from octotablet (Wayland/X11 tablet protocols, Windows Ink),
//! on Android as touch samples with the tool type stylus or eraser, which the patched winit
//! hands over separately (`winit::platform::android::take_pen_samples`). Both are turned into
//! the same [`PenInput`] events, which the app handles in one place.
use std::time::{Duration, Instant};
/// What happened to the pen.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PenEvent {
/// Pen comes into range (hovers above the screen)
In,
/// Pen left the range
Out,
/// Tip touches the screen (at the position of the last `Pose`)
Down,
/// Tip lifted
Up,
/// Position in logical pixels; pressure 0..=1 if known
Pose {
position: [f32; 2],
pressure: Option<f32>,
},
/// Pen button pressed or released; `key` identifies the button across sessions
Button { key: u64, pressed: bool },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PenInput {
/// Identifies the pen (several pens can be in use on the desktop)
pub tool: u64,
/// Eraser end of the pen
pub is_eraser: bool,
pub event: PenEvent,
}
fn hash_of(value: impl std::hash::Hash) -> u64 {
use std::hash::Hasher;
// DefaultHasher::new() uses fixed keys: same input, same value in every session
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
/// Turns octotablet's events into [`PenInput`]s, with each `Down` after the `Pose` of the
/// same frame (see [`crate::platform::down_after_pose`]).
#[cfg(not(target_os = "android"))]
pub fn from_octotablet<'a>(
events: impl IntoIterator<Item = octotablet::events::Event<'a>>,
) -> Vec<PenInput> {
use octotablet::events::{Event, ToolEvent};
let inputs = events.into_iter().filter_map(|event| {
let Event::Tool { tool, event } = event else {
return None;
};
let event = match event {
ToolEvent::In { .. } => PenEvent::In,
ToolEvent::Out | ToolEvent::Removed => PenEvent::Out,
ToolEvent::Down => PenEvent::Down,
ToolEvent::Up => PenEvent::Up,
ToolEvent::Pose(pose) => PenEvent::Pose {
position: pose.position,
pressure: pose.pressure.get(),
},
ToolEvent::Button { button_id, pressed } => PenEvent::Button {
key: hash_of(button_id),
pressed,
},
_ => return None,
};
Some(PenInput {
tool: hash_of(tool.id()),
is_eraser: tool.tool_type == Some(octotablet::tool::Type::Eraser),
event,
})
});
crate::platform::down_after_pose(
inputs,
|i| i.event == PenEvent::Down,
|i| matches!(i.event, PenEvent::Pose { .. }),
)
}
/// What an Android pen sample reports (mirrors `winit::platform::android::PenAction`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleAction {
HoverEnter,
HoverMove,
HoverExit,
Down,
Move,
Up,
Cancel,
}
/// One Android pen sample (mirrors `winit::platform::android::PenSample`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Sample {
pub action: SampleAction,
/// Physical pixels
pub x: f32,
pub y: f32,
pub pressure: f32,
pub eraser: bool,
/// `MotionEvent.getButtonState()`
pub buttons: u32,
}
#[cfg(target_os = "android")]
impl From<winit::platform::android::PenSample> for Sample {
fn from(s: winit::platform::android::PenSample) -> Self {
use winit::platform::android::PenAction as A;
Self {
action: match s.action {
A::HoverEnter => SampleAction::HoverEnter,
A::HoverMove => SampleAction::HoverMove,
A::HoverExit => SampleAction::HoverExit,
A::Down => SampleAction::Down,
A::Move => SampleAction::Move,
A::Up => SampleAction::Up,
A::Cancel => SampleAction::Cancel,
},
x: s.x,
y: s.y,
pressure: s.pressure,
eraser: s.eraser,
buttons: s.buttons,
}
}
}
/// The two pen buttons of Android's `MotionEvent` (`BUTTON_STYLUS_PRIMARY`/`SECONDARY`).
const ANDROID_BUTTONS: [(u32, &str); 2] = [
(0x20, "android-stylus-primary"),
(0x40, "android-stylus-secondary"),
];
/// How long the pen may be out of sight before it counts as gone. Android reports
/// `HoverExit` right before the tip touches down, and pens without hover report nothing
/// after lifting.
const ANDROID_OUT_DELAY: Duration = Duration::from_millis(150);
/// Turns Android pen samples into [`PenInput`]s.
#[derive(Debug, Default)]
pub struct AndroidPen {
in_range: bool,
down: bool,
eraser: bool,
buttons: u32,
/// Since when the pen is neither hovering nor touching
gone_since: Option<Instant>,
}
impl AndroidPen {
/// The single pen Android reports
const TOOL: u64 = 1;
fn push(&self, out: &mut Vec<PenInput>, event: PenEvent) {
out.push(PenInput {
tool: Self::TOOL,
is_eraser: self.eraser,
event,
});
}
/// Events for `samples` (in this order); `scale` converts physical to logical pixels.
pub fn convert(&mut self, samples: &[Sample], scale: f32, now: Instant) -> Vec<PenInput> {
let mut out = Vec::new();
for sample in samples {
self.eraser = sample.eraser;
for (mask, name) in ANDROID_BUTTONS {
let pressed = sample.buttons & mask != 0;
if pressed != (self.buttons & mask != 0) {
self.push(
&mut out,
PenEvent::Button {
key: hash_of(name),
pressed,
},
);
}
}
self.buttons = sample.buttons;
let position = [sample.x / scale, sample.y / scale];
let touching = Some(sample.pressure.clamp(0.0, 1.0));
match sample.action {
SampleAction::HoverEnter | SampleAction::HoverMove => {
self.gone_since = None;
self.enter(&mut out);
let pose = PenEvent::Pose {
position,
pressure: None,
};
self.push(&mut out, pose);
}
SampleAction::HoverExit => {
if !self.down {
self.gone_since.get_or_insert(now);
}
}
SampleAction::Down => {
self.gone_since = None;
self.enter(&mut out);
let pose = PenEvent::Pose {
position,
pressure: touching,
};
self.push(&mut out, pose);
if !self.down {
self.down = true;
self.push(&mut out, PenEvent::Down);
}
}
SampleAction::Move => {
self.gone_since = None;
self.enter(&mut out);
let pressure = if self.down { touching } else { None };
self.push(&mut out, PenEvent::Pose { position, pressure });
}
SampleAction::Up | SampleAction::Cancel => {
if self.down {
if sample.action == SampleAction::Up {
let pose = PenEvent::Pose {
position,
pressure: touching,
};
self.push(&mut out, pose);
}
self.down = false;
self.push(&mut out, PenEvent::Up);
}
self.gone_since = Some(now);
}
}
}
out.extend(self.poll(now));
out
}
fn enter(&mut self, out: &mut Vec<PenInput>) {
if !self.in_range {
self.in_range = true;
self.push(out, PenEvent::In);
}
}
/// `Out` once the pen has been gone for a moment; call regularly while it is in range.
pub fn poll(&mut self, now: Instant) -> Vec<PenInput> {
let mut out = Vec::new();
let gone = self
.gone_since
.is_some_and(|since| now.duration_since(since) >= ANDROID_OUT_DELAY);
if gone && self.in_range && !self.down {
self.in_range = false;
self.gone_since = None;
if self.buttons != 0 {
for (mask, name) in ANDROID_BUTTONS {
if self.buttons & mask != 0 {
self.push(
&mut out,
PenEvent::Button {
key: hash_of(name),
pressed: false,
},
);
}
}
self.buttons = 0;
}
self.push(&mut out, PenEvent::Out);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(action: SampleAction, x: f32, pressure: f32, buttons: u32) -> Sample {
Sample {
action,
x,
y: 10.0,
pressure,
eraser: false,
buttons,
}
}
fn kinds(inputs: &[PenInput]) -> Vec<&'static str> {
inputs
.iter()
.map(|i| match i.event {
PenEvent::In => "in",
PenEvent::Out => "out",
PenEvent::Down => "down",
PenEvent::Up => "up",
PenEvent::Pose { pressure: None, .. } => "hover",
PenEvent::Pose { .. } => "pose",
PenEvent::Button { pressed: true, .. } => "button-down",
PenEvent::Button { .. } => "button-up",
})
.collect()
}
#[test]
fn test_android_stroke_with_hover() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let ms = |n| t + Duration::from_millis(n);
let out = pen.convert(&[sample(HoverEnter, 20.0, 0.0, 0)], 2.0, t);
assert_eq!(kinds(&out), ["in", "hover"]);
assert_eq!(
out[1].event,
PenEvent::Pose {
position: [10.0, 5.0],
pressure: None
}
);
// Android ends the hover right before the tip touches: no Out in between
let out = pen.convert(&[sample(HoverExit, 20.0, 0.0, 0)], 2.0, ms(5));
assert!(out.is_empty());
let out = pen.convert(
&[sample(Down, 20.0, 0.4, 0), sample(Move, 30.0, 0.5, 0)],
2.0,
ms(10),
);
assert_eq!(kinds(&out), ["pose", "down", "pose"]);
assert!(pen.poll(ms(500)).is_empty(), "still touching");
let out = pen.convert(&[sample(Up, 30.0, 0.0, 0)], 2.0, ms(600));
assert_eq!(kinds(&out), ["pose", "up"]);
let out = pen.convert(&[sample(HoverEnter, 30.0, 0.0, 0)], 2.0, ms(610));
assert_eq!(kinds(&out), ["hover"]);
pen.convert(&[sample(HoverExit, 30.0, 0.0, 0)], 2.0, ms(700));
assert!(pen.poll(ms(750)).is_empty());
assert_eq!(kinds(&pen.poll(ms(900))), ["out"]);
}
#[test]
fn test_android_pen_without_hover_leaves_after_lifting() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let out = pen.convert(&[sample(Down, 0.0, 0.7, 0)], 1.0, t);
assert_eq!(kinds(&out), ["in", "pose", "down"]);
pen.convert(&[sample(Up, 0.0, 0.0, 0)], 1.0, t);
assert_eq!(kinds(&pen.poll(t + Duration::from_millis(200))), ["out"]);
}
#[test]
fn test_android_buttons_and_cancel() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let out = pen.convert(
&[
sample(HoverMove, 0.0, 0.0, 0x20),
sample(Down, 0.0, 0.5, 0x20),
sample(Cancel, 0.0, 0.0, 0x20),
],
1.0,
t,
);
assert_eq!(
kinds(&out),
["button-down", "in", "hover", "pose", "down", "up"]
);
// Pen gone with the button still held: released before Out
let out = pen.poll(t + Duration::from_millis(200));
assert_eq!(kinds(&out), ["button-up", "out"]);
}
}