mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
feat(web): Apple Pencil input on iPad with palm rejection
Read pen and finger Pointer Events in the web shell (capture phase) instead of eframe's touch handlers, which can't tell a pen from a finger and see every touch twice. The pen drives the pointer with its pressure and hovers; fingers are touch contacts for pinch, two-finger pan and undo/redo taps. Once a pen has been seen (remembered in localStorage), a finger or a resting palm never presses a tool on the canvas, and a pen landing cancels fingers already down. A long press drops the tool press and opens the context menu. The page no longer scrolls, zooms or selects under touch, and the canvas stays inside the safe areas of a full-screen app.
This commit is contained in:
@@ -28,4 +28,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="VectorCraft">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="theme-color" content="#262626">
|
||||
<title>VectorCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#vectorcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626;
|
||||
overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
|
||||
/* Inside the safe areas (notch, home indicator) of a full-screen app. */
|
||||
#vectorcraft_canvas { position: absolute; top: env(safe-area-inset-top, 0); right: env(safe-area-inset-right, 0);
|
||||
bottom: env(safe-area-inset-bottom, 0); left: env(safe-area-inset-left, 0);
|
||||
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
|
||||
height: calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
display: block; touch-action: none; }
|
||||
#vectorcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||
//! `Services::inbox`;
|
||||
//! - Save / Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
|
||||
//! hover, palm rejection, fingers navigate once a pen has been seen).
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
@@ -19,6 +21,8 @@ mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod pencil;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in
|
||||
//! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from
|
||||
//! a finger and see each touch twice (as a pointer and as a touch event).
|
||||
//!
|
||||
//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the
|
||||
//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers
|
||||
//! where the pen can (Apple Pencil hover).
|
||||
//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first
|
||||
//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates
|
||||
//! only once a pen has been seen ([`touch::set_source`]), which the page remembers.
|
||||
//! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers
|
||||
//! already down (a palm resting first).
|
||||
//!
|
||||
//! Mouse and trackpad events (a Magic Keyboard) are left to eframe.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
|
||||
use vectorcraft_ui_egui::touch::{self, Source};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
/// Where the page remembers that a pen has been seen (`localStorage`).
|
||||
const PEN_SEEN_KEY: &str = "vectorcraft-pen-seen";
|
||||
/// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger).
|
||||
const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1);
|
||||
const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2);
|
||||
|
||||
/// The pen and finger contacts followed, and the egui events they made since the last frame.
|
||||
#[derive(Default)]
|
||||
pub struct Pointers {
|
||||
events: Vec<Event>,
|
||||
/// The pen pressing, by pointer id.
|
||||
pen: Option<i32>,
|
||||
/// The fingers down, by pointer id.
|
||||
fingers: Vec<i32>,
|
||||
/// The contact driving egui's pointer.
|
||||
primary: Option<i32>,
|
||||
}
|
||||
|
||||
pub type SharedPointers = Rc<RefCell<Pointers>>;
|
||||
|
||||
impl Pointers {
|
||||
/// The events since the last frame, for the frame's raw input.
|
||||
pub fn take(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
|
||||
self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force });
|
||||
}
|
||||
|
||||
fn press(&mut self, id: i32, pos: Pos2) {
|
||||
self.primary = Some(id);
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::default() });
|
||||
}
|
||||
|
||||
fn release(&mut self, pos: Pos2) {
|
||||
self.primary = None;
|
||||
self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::default() });
|
||||
}
|
||||
|
||||
/// Cancel every finger down (a palm under a pen landing).
|
||||
fn cancel_fingers(&mut self, pos: Pos2) {
|
||||
for id in std::mem::take(&mut self.fingers) {
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.release(pos);
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe).
|
||||
fn feed(&mut self, kind: &str, e: &web_sys::PointerEvent, pos: Pos2, on_canvas: bool) -> bool {
|
||||
let id = e.pointer_id();
|
||||
match e.pointer_type().as_str() {
|
||||
"pen" => {
|
||||
let force = Some(e.pressure().clamp(0.0, 1.0));
|
||||
match kind {
|
||||
"pointerdown" => {
|
||||
if !on_canvas && self.pen.is_none() {
|
||||
return false;
|
||||
}
|
||||
set_pen_seen();
|
||||
touch::set_source(Source::Pen);
|
||||
self.cancel_fingers(pos);
|
||||
self.pen = Some(id);
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force);
|
||||
self.press(id, pos);
|
||||
}
|
||||
"pointermove" if self.pen == Some(id) => {
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force);
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
// Hovering (Apple Pencil hover): the pointer follows without pressing.
|
||||
"pointermove" => {
|
||||
if self.primary.is_some() {
|
||||
return true;
|
||||
}
|
||||
if !on_canvas {
|
||||
return false;
|
||||
}
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
"pointerup" | "pointercancel" if self.pen == Some(id) => {
|
||||
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
|
||||
self.pen = None;
|
||||
self.touch(PEN_DEVICE, id, phase, pos, None);
|
||||
self.release(pos);
|
||||
}
|
||||
// A pen leaving the hover range, or an event of a press we didn't take.
|
||||
_ => {
|
||||
if kind == "pointerleave" && self.primary.is_none() && on_canvas {
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
return self.pen.is_some();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
"touch" => {
|
||||
match kind {
|
||||
"pointerdown" => {
|
||||
// Palm rejection: nothing presses while the pen does.
|
||||
if self.pen.is_some() {
|
||||
return true;
|
||||
}
|
||||
if !on_canvas && self.fingers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.fingers.push(id);
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None);
|
||||
if self.primary.is_none() && self.fingers.len() == 1 {
|
||||
touch::set_source(Source::Finger);
|
||||
self.press(id, pos);
|
||||
}
|
||||
}
|
||||
"pointermove" if self.fingers.contains(&id) => {
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
}
|
||||
"pointerup" | "pointercancel" if self.fingers.contains(&id) => {
|
||||
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
|
||||
self.fingers.retain(|f| *f != id);
|
||||
self.touch(FINGER_DEVICE, id, phase, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.release(pos);
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
}
|
||||
// A rejected palm's later events, or one we never took.
|
||||
_ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas,
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the page remembers a pen, for [`touch::set_pen_seen`] before the first frame.
|
||||
pub fn restore_pen_seen() {
|
||||
let seen = local_storage().and_then(|s| s.get_item(PEN_SEEN_KEY).ok().flatten()).is_some_and(|v| v == "1");
|
||||
touch::set_pen_seen(seen);
|
||||
}
|
||||
|
||||
fn set_pen_seen() {
|
||||
if !touch::pen_seen()
|
||||
&& let Some(s) = local_storage()
|
||||
{
|
||||
// Private browsing can refuse it: fingers then draw again on the next visit, no more.
|
||||
let _ = s.set_item(PEN_SEEN_KEY, "1");
|
||||
}
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
/// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch
|
||||
/// events that follow them), for the runner's frames. Mouse events stay eframe's.
|
||||
pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into();
|
||||
let options = web_sys::AddEventListenerOptions::new();
|
||||
options.set_capture(true);
|
||||
options.set_passive(false);
|
||||
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
|
||||
let (pointers, canvas) = (pointers.clone(), canvas.clone());
|
||||
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| {
|
||||
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
|
||||
let rect = canvas.get_bounding_client_rect();
|
||||
let z = runner.egui_ctx().zoom_factor();
|
||||
let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z);
|
||||
if !pointers.borrow_mut().feed(kind, &e, pos, on_canvas) {
|
||||
return;
|
||||
}
|
||||
e.stop_immediate_propagation();
|
||||
// No compatibility mouse events, no text selection or magnifier.
|
||||
e.prevent_default();
|
||||
if kind == "pointerup" {
|
||||
// Keys typed next (a Magic Keyboard) go to the app.
|
||||
canvas.focus().ok();
|
||||
// Safari lets a page open the share sheet, a download or the on-screen keyboard
|
||||
// only during the user's gesture: handle the release now.
|
||||
runner.logic();
|
||||
}
|
||||
runner.egui_ctx().request_repaint();
|
||||
})?;
|
||||
}
|
||||
// The touch events behind those pointer events: eframe would see each touch a second time,
|
||||
// and Safari would scroll, zoom or select.
|
||||
for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] {
|
||||
let (pointers, canvas) = (pointers.clone(), canvas.clone());
|
||||
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| {
|
||||
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
|
||||
let p = pointers.borrow();
|
||||
if on_canvas || p.pen.is_some() || !p.fingers.is_empty() {
|
||||
e.stop_immediate_propagation();
|
||||
if e.cancelable() {
|
||||
e.prevent_default();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
@@ -47,6 +48,8 @@ struct Host {
|
||||
place_inbox: PlaceInbox,
|
||||
drag: DragPos,
|
||||
pasted: Rc<RefCell<Pasted>>,
|
||||
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
|
||||
pointers: SharedPointers,
|
||||
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
|
||||
/// restart keeps them, so the tab stays the owner of its recovery area.
|
||||
locks: Option<WebLocks>,
|
||||
@@ -61,6 +64,7 @@ pub fn start() {
|
||||
return;
|
||||
};
|
||||
detect_language();
|
||||
crate::pencil::restore_pen_seen();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Before the first frame, which looks for copies a crash left behind.
|
||||
let locks = WebLocks::start(RECOVERY_PREFIX).await;
|
||||
@@ -73,6 +77,7 @@ pub fn start() {
|
||||
place_inbox: Arc::default(),
|
||||
drag: DragPos::default(),
|
||||
pasted: Rc::default(),
|
||||
pointers: SharedPointers::default(),
|
||||
locks,
|
||||
restarts: 0,
|
||||
};
|
||||
@@ -96,6 +101,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The app until the new runner takes it (still here if starting fails before).
|
||||
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
|
||||
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
|
||||
let (pointers, pen_canvas) = (host.pointers.clone(), canvas.clone());
|
||||
let result = runner
|
||||
.start(
|
||||
canvas,
|
||||
@@ -127,6 +133,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
.await;
|
||||
match (result, kept) {
|
||||
(Ok(()), _) => {
|
||||
if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) {
|
||||
log::error!("vectorcraft-web: no pen input: {}", js_err(e));
|
||||
}
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.remove();
|
||||
}
|
||||
@@ -381,6 +390,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
raw.events.extend(self.host.pointers.borrow_mut().take());
|
||||
if let Some(app) = &mut self.app {
|
||||
app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
@@ -569,6 +569,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
app.hover_doc = hover.map(|p| xf.to_doc(p));
|
||||
let view = app.view_info();
|
||||
touch_gestures(app, ui, rect, view);
|
||||
// Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the
|
||||
// tool's press is dropped, as a right-click presses nothing.
|
||||
if resp.long_touched() {
|
||||
drop_press(app, ui, view);
|
||||
}
|
||||
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||||
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
|
||||
// without moving the mouse (Alt switches the Shape Builder to erase mode).
|
||||
@@ -610,7 +615,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
{
|
||||
ui.ctx().memory_mut(|mem| mem.stop_text_input());
|
||||
app.ui.flyout = None;
|
||||
let d = if zoom_mode {
|
||||
let d = if crate::touch::finger_navigates_only() {
|
||||
// A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw.
|
||||
Drag::Gesture
|
||||
} else if zoom_mode {
|
||||
if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } }
|
||||
} else if pan_mode {
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
@@ -890,13 +898,13 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec
|
||||
if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) {
|
||||
continue;
|
||||
}
|
||||
// A cancelled contact (a palm the shell drops when a pen lands) keeps nothing it began.
|
||||
if phase == egui::TouchPhase::Cancel {
|
||||
drop_press(app, ui, view);
|
||||
}
|
||||
match taps.feed(id, phase, pos, now) {
|
||||
Some(crate::touch::Touch::Fingers) => {
|
||||
if ui.data(|d| d.get_temp::<Drag>(drag_id())) == Some(Drag::Tool) {
|
||||
// Nothing it began is kept; the tool's mouse-up returns it to rest.
|
||||
let _ = app.session.cancel_interaction();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
}
|
||||
drop_press(app, ui, view);
|
||||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||||
}
|
||||
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n),
|
||||
@@ -909,6 +917,21 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the tool press in progress, if any: nothing it began is kept, and the rest of the press
|
||||
/// does nothing ([`Drag::Gesture`]).
|
||||
fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) {
|
||||
match ui.data(|d| d.get_temp::<Drag>(drag_id())) {
|
||||
Some(Drag::Tool) => {
|
||||
// The tool's mouse-up returns it to rest.
|
||||
let _ = app.session.cancel_interaction();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => return,
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||||
}
|
||||
|
||||
/// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of
|
||||
/// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it
|
||||
/// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it).
|
||||
|
||||
@@ -55,7 +55,7 @@ pub mod theme;
|
||||
pub mod thumb_worker;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
mod touch;
|
||||
pub mod touch;
|
||||
mod ui_fonts;
|
||||
pub mod unsaved;
|
||||
pub mod widgets;
|
||||
|
||||
@@ -5,9 +5,56 @@
|
||||
//! system: `WM_POINTER` on Windows), and makes the first finger the mouse pointer too. So the first
|
||||
//! finger has pressed on the canvas by the time a second lands: that press is dropped then
|
||||
//! ([`Taps::feed`] says [`Touch::Fingers`]), and the tap undoes what came before it.
|
||||
//!
|
||||
//! Where the platform tells a pen from a finger (the web shell reads Pointer Events: an iPad with
|
||||
//! an Apple Pencil), it says which drives the pointer ([`set_source`]). Once a pen has been seen,
|
||||
//! fingers only navigate: pinch, two-finger pan and the taps; a finger or a resting palm never
|
||||
//! presses a tool on the canvas.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use egui::{Pos2, TouchId, TouchPhase};
|
||||
|
||||
/// What drives the pointer.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// A mouse or trackpad, or a platform that can't tell (the desktop apps).
|
||||
#[default]
|
||||
Mouse,
|
||||
/// A pen (Apple Pencil, a stylus).
|
||||
Pen,
|
||||
/// A finger on a touch screen.
|
||||
Finger,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SOURCE: Cell<Source> = const { Cell::new(Source::Mouse) };
|
||||
static PEN_SEEN: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The pointer's presses now come from `source`; a pen makes fingers navigate only from then on.
|
||||
pub fn set_source(source: Source) {
|
||||
SOURCE.with(|s| s.set(source));
|
||||
if source == Source::Pen {
|
||||
PEN_SEEN.with(|p| p.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a pen has been seen (the shell restores it across visits), so fingers navigate only.
|
||||
pub fn set_pen_seen(seen: bool) {
|
||||
PEN_SEEN.with(|p| p.set(seen));
|
||||
}
|
||||
|
||||
/// A pen has been seen.
|
||||
pub fn pen_seen() -> bool {
|
||||
PEN_SEEN.with(Cell::get)
|
||||
}
|
||||
|
||||
/// The press in progress is a finger's while a pen has been seen: it navigates, it doesn't draw.
|
||||
pub(crate) fn finger_navigates_only() -> bool {
|
||||
SOURCE.with(Cell::get) == Source::Finger && pen_seen()
|
||||
}
|
||||
|
||||
/// How long the fingers of a tap may stay down, in seconds.
|
||||
const TAP_TIME: f64 = 0.5;
|
||||
/// How far (points) a finger of a tap may move: further, it is a pinch or a pan.
|
||||
@@ -128,4 +175,18 @@ mod tests {
|
||||
assert_eq!(gesture(0.0, 0.2, TouchPhase::Cancel, 2), None, "cancelled");
|
||||
assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 1), None, "a pen or one finger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingers_navigate_only_once_a_pen_was_seen() {
|
||||
set_pen_seen(false);
|
||||
set_source(Source::Finger);
|
||||
assert!(!finger_navigates_only(), "no pen yet: a finger draws");
|
||||
set_source(Source::Pen);
|
||||
assert!(pen_seen() && !finger_navigates_only());
|
||||
set_source(Source::Finger);
|
||||
assert!(finger_navigates_only());
|
||||
set_source(Source::Mouse);
|
||||
assert!(!finger_navigates_only(), "a mouse still draws");
|
||||
set_pen_seen(false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user