Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa125bfff1 | ||
|
|
e037064b2d | ||
|
|
564a3ae662 | ||
|
|
106abe639f | ||
|
|
54eed6ec4c |
No files matched your search
+1
-1
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour};
|
||||
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
@@ -200,7 +200,8 @@ fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
|
||||
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
|
||||
let Some(data) = e.clipboard_data() else { return };
|
||||
let Some(files) = data.files().filter(|f| f.length() > 0) else { return };
|
||||
if data.get_data("text").is_ok_and(|t| !t.is_empty()) {
|
||||
// Text is egui's, but for the address a browser's Copy Image puts next to the picture.
|
||||
if data.get_data("text").is_ok_and(|t| !t.is_empty() && !is_address(&t)) {
|
||||
return;
|
||||
}
|
||||
e.prevent_default();
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
@@ -220,6 +220,25 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// The address a browser's Copy Image puts next to the picture (#597), and text that isn't one.
|
||||
#[test]
|
||||
fn an_address_alone_is_told_from_other_text() {
|
||||
for a in [
|
||||
"https://example.com/cat.png",
|
||||
" http://x.org/a?b=1
|
||||
",
|
||||
"HTTPS://EXAMPLE.COM",
|
||||
"file:///C:/art/cat.png",
|
||||
"data:image/png;base64,iVBOR",
|
||||
"blob:https://x.org/1",
|
||||
] {
|
||||
assert!(is_address(a), "{a}");
|
||||
}
|
||||
for t in ["Pasted words", "see https://example.com", "https://a.org https://b.org", "https://", "example.com/cat.png", ""] {
|
||||
assert!(!is_address(t), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_exports_svg_that_round_trips_through_paste() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -30,6 +30,15 @@ pub const EMF: &str = "image/emf";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
|
||||
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
|
||||
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
|
||||
/// address alone pastes as text, and nothing is fetched.
|
||||
pub fn is_address(text: &str) -> bool {
|
||||
const SCHEMES: [&str; 6] = ["http://", "https://", "file://", "ftp://", "data:", "blob:"];
|
||||
let t = text.trim();
|
||||
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
|
||||
}
|
||||
|
||||
/// How much of a copied file [`file_flavour`] needs to tell what it is.
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ enum Drag {
|
||||
/// A Selection tool move dragged off the canvas: the panels get the art
|
||||
/// ([`widgets::PanelDrag::Art`]).
|
||||
Art,
|
||||
/// Fingers making a touch gesture ([`crate::touch`]): the first one's press does nothing.
|
||||
Gesture,
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
@@ -474,6 +476,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p)));
|
||||
app.hover_doc = hover.map(|p| xf.to_doc(p));
|
||||
let view = app.view_info();
|
||||
touch_gestures(app, ui, rect, 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).
|
||||
@@ -565,7 +568,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
vm.rotation = vectorcraft_geom::normalize_deg(deg);
|
||||
}
|
||||
}
|
||||
Drag::ZoomBox { .. } | Drag::Art => {}
|
||||
Drag::ZoomBox { .. } | Drag::Art | Drag::Gesture => {}
|
||||
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
|
||||
}
|
||||
@@ -609,7 +612,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
Drag::Art => {}
|
||||
Drag::Art | Drag::Gesture => {}
|
||||
Drag::Pan { .. } | Drag::RotateView { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +691,50 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
|
||||
(zoom, scroll)
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
|
||||
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
|
||||
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes.
|
||||
fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
|
||||
if !app.session.prefs.touch_gestures {
|
||||
return;
|
||||
}
|
||||
let (touches, now): (Vec<_>, f64) = ui.input(|i| {
|
||||
let t = i.events.iter().filter_map(|e| match e {
|
||||
egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)),
|
||||
_ => None,
|
||||
});
|
||||
(t.collect(), i.time)
|
||||
});
|
||||
if touches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::new("canvas-touch");
|
||||
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
|
||||
let mut tap = None;
|
||||
for (id, phase, pos) in touches {
|
||||
// A gesture begins on the canvas.
|
||||
if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||||
}
|
||||
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, taps));
|
||||
if let Some(cmd) = tap {
|
||||
crate::menus::invoke(app, cmd, serde_json::json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -1865,6 +1912,40 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
fn two_finger_tap_undoes_and_three_finger_tap_redoes() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
// `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a
|
||||
// little (as a rectangle drag would begin).
|
||||
let tap = |app: &mut VectorcraftApp, fingers: u64| {
|
||||
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
|
||||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]);
|
||||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]);
|
||||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect());
|
||||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]);
|
||||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect());
|
||||
frame(app, &ctx, vec![]);
|
||||
};
|
||||
tap(&mut app, 2);
|
||||
assert_eq!(count(&app), 0, "the rectangle undone, none drawn");
|
||||
tap(&mut app, 3);
|
||||
assert_eq!(count(&app), 1, "redone");
|
||||
app.session.prefs.touch_gestures = false;
|
||||
tap(&mut app, 2);
|
||||
assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone");
|
||||
}
|
||||
|
||||
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
|
||||
/// labels are interface text.
|
||||
#[test]
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod sysclip;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
mod touch;
|
||||
mod ui_fonts;
|
||||
pub mod unsaved;
|
||||
pub mod widgets;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! reads from a paste itself ([`VectorcraftApp::paste_from_host`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, is_address, looks_like_svg};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
@@ -90,6 +90,8 @@ impl VectorcraftApp {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) };
|
||||
// Text that is only an address goes with a browser's copied picture: the picture.
|
||||
let f = if f.mime == TEXT && is_address(&String::from_utf8_lossy(&f.data)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f };
|
||||
import_command(&f, center)
|
||||
}
|
||||
// Text only: SVG markup is art, other text is left alone.
|
||||
|
||||
@@ -167,6 +167,22 @@ fn text_from_another_app_pastes_as_point_text_and_svg_text_as_art() {
|
||||
assert_eq!(count(&app), before);
|
||||
}
|
||||
|
||||
/// A browser's Copy Image: the picture with its address as text (and HTML, which Paste doesn't
|
||||
/// read). The picture is pasted; an address alone pastes as text, nothing is fetched (#597).
|
||||
#[test]
|
||||
fn an_image_copied_in_a_browser_pastes_as_the_picture_not_its_address() {
|
||||
let (mut app, board) = app();
|
||||
let url = b"https://example.com/images/cat.png".to_vec();
|
||||
copy_elsewhere(&board, vec![(TEXT, url.clone()), ("text/html", b"<img src=\"https://example.com/images/cat.png\">".to_vec()), (PNG, blue_png())]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
let NodeKind::Image(im) = &pasted(&app)[0].kind else { panic!("not an image: {:?}", pasted(&app)[0].kind) };
|
||||
assert!(im.link.is_none() && (im.width, im.height) == (4, 2));
|
||||
copy_elsewhere(&board, vec![(TEXT, url)]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") };
|
||||
assert_eq!(t.plain_text(), "https://example.com/images/cat.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() {
|
||||
let (mut app, board) = app();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Touch gestures on the canvas (Preferences › Devices › Enable Touch Gestures, #585): a tap with
|
||||
//! two fingers undoes, one with three redoes.
|
||||
//!
|
||||
//! egui hands every finger's contact over as [`egui::Event::Touch`] (winit reads them from the
|
||||
//! 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.
|
||||
|
||||
use egui::{Pos2, TouchId, TouchPhase};
|
||||
|
||||
/// 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.
|
||||
const TAP_SLOP: f32 = 16.0;
|
||||
|
||||
/// What a touch event made of the gesture.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Touch {
|
||||
/// A second finger landed: the gesture isn't the first finger's press.
|
||||
Fingers,
|
||||
/// All fingers lifted from a tap of this many fingers (two or more).
|
||||
Tap(usize),
|
||||
}
|
||||
|
||||
/// The fingers down and how the gesture they make has gone so far.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Taps {
|
||||
/// Each finger down and where it landed.
|
||||
down: Vec<(TouchId, Pos2)>,
|
||||
/// The most fingers down at once in this gesture.
|
||||
most: usize,
|
||||
/// When its first finger landed.
|
||||
start: f64,
|
||||
/// A finger moved too far or stayed too long: not a tap.
|
||||
spoilt: bool,
|
||||
}
|
||||
|
||||
impl Taps {
|
||||
/// Follow a finger's `phase` at `pos` (touch `id`) at time `now` (seconds).
|
||||
pub(crate) fn feed(&mut self, id: TouchId, phase: TouchPhase, pos: Pos2, now: f64) -> Option<Touch> {
|
||||
match phase {
|
||||
TouchPhase::Start => {
|
||||
if self.down.is_empty() {
|
||||
*self = Self { start: now, ..Self::default() };
|
||||
}
|
||||
if !self.down.iter().any(|(d, _)| *d == id) {
|
||||
self.down.push((id, pos));
|
||||
}
|
||||
let before = self.most;
|
||||
self.most = self.most.max(self.down.len());
|
||||
(before < 2 && self.most >= 2).then_some(Touch::Fingers)
|
||||
}
|
||||
TouchPhase::Move => {
|
||||
if self.down.iter().any(|(d, at)| *d == id && at.distance(pos) > TAP_SLOP) {
|
||||
self.spoilt = true;
|
||||
}
|
||||
None
|
||||
}
|
||||
TouchPhase::End | TouchPhase::Cancel => {
|
||||
let known = self.down.len();
|
||||
self.down.retain(|(d, _)| *d != id);
|
||||
if self.down.len() == known {
|
||||
return None;
|
||||
}
|
||||
self.spoilt |= phase == TouchPhase::Cancel || now - self.start > TAP_TIME;
|
||||
(self.down.is_empty() && !self.spoilt && self.most >= 2).then_some(Touch::Tap(self.most))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger is down.
|
||||
pub(crate) fn is_down(&self) -> bool {
|
||||
!self.down.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a tap of `fingers` runs: two undo, three redo.
|
||||
pub(crate) fn tap_command(fingers: usize) -> Option<&'static str> {
|
||||
match fingers {
|
||||
2 => Some("edit.undo"),
|
||||
3 => Some("edit.redo"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn id(n: u64) -> TouchId {
|
||||
TouchId(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_and_three_finger_taps() {
|
||||
let mut t = Taps::default();
|
||||
let p = Pos2::new(100.0, 100.0);
|
||||
assert_eq!(t.feed(id(1), TouchPhase::Start, p, 0.0), None);
|
||||
assert_eq!(t.feed(id(2), TouchPhase::Start, p + egui::vec2(60.0, 0.0), 0.05), Some(Touch::Fingers));
|
||||
assert!(t.is_down());
|
||||
assert_eq!(t.feed(id(1), TouchPhase::Move, p + egui::vec2(3.0, 2.0), 0.1), None);
|
||||
assert_eq!(t.feed(id(1), TouchPhase::End, p, 0.15), None, "one finger still down");
|
||||
assert_eq!(t.feed(id(2), TouchPhase::End, p, 0.2), Some(Touch::Tap(2)));
|
||||
assert!(!t.is_down());
|
||||
// Three fingers.
|
||||
for (n, at) in [(4, 0.0), (5, 0.02), (6, 0.04)] {
|
||||
t.feed(id(n), TouchPhase::Start, p, 1.0 + at);
|
||||
}
|
||||
let ends: Vec<_> = [4, 5, 6].into_iter().map(|n| t.feed(id(n), TouchPhase::End, p, 1.2)).collect();
|
||||
assert_eq!(ends, [None, None, Some(Touch::Tap(3))]);
|
||||
assert_eq!((tap_command(2), tap_command(3), tap_command(1)), (Some("edit.undo"), Some("edit.redo"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinch_a_long_press_a_cancel_or_one_finger_is_no_tap() {
|
||||
let p = Pos2::new(100.0, 100.0);
|
||||
let gesture = |moved: f32, held: f64, last: TouchPhase, fingers: u64| {
|
||||
let mut t = Taps::default();
|
||||
for n in 0..fingers {
|
||||
t.feed(id(n), TouchPhase::Start, p, 0.0);
|
||||
}
|
||||
t.feed(id(0), TouchPhase::Move, p + egui::vec2(moved, 0.0), held / 2.0);
|
||||
(0..fingers).filter_map(|n| t.feed(id(n), if n + 1 == fingers { last } else { TouchPhase::End }, p, held)).last()
|
||||
};
|
||||
assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 2), Some(Touch::Tap(2)));
|
||||
assert_eq!(gesture(40.0, 0.2, TouchPhase::End, 2), None, "a pinch");
|
||||
assert_eq!(gesture(0.0, 0.9, TouchPhase::End, 2), None, "held too long");
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user