diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index e818c36862..b34ef1d776 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1060,6 +1060,12 @@ impl Session { self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) } + /// Has the active tool started something that Escape ends while no button is pressed (see + /// `Tool::ends_on_escape`)? + pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool { + self.with_tool_cx(view, |t, cx| t.ends_on_escape(cx)) + } + pub fn overlays(&mut self, view: ViewInfo) -> Vec { // The Blend tool's first pick is not marked after an edit that dropped it. self.tell_tool_history_moved(); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 13c4268472..eed8ecd335 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -98,7 +98,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: brushes: None, knockout: doc.page_knockout, anonymous: false, - knockout_filter: None, + knockout_filters: HashMap::new(), + region_boxes: None, + art_reach: HashMap::new(), warnings: vec![], id_prefix, linked: Vec::new(), @@ -255,8 +257,13 @@ struct Writer<'a> { knockout: bool, /// Writing a copy of an object (a knockout mask): no object ids, so they stay unique. anonymous: bool, - /// The filter that paints art black keeping its alpha (knockout masks), once defined. - knockout_filter: Option, + /// The filters that paint art black keeping its alpha (knockout masks), by the region + /// attributes they were defined with. + knockout_filters: HashMap, + /// The document's symbol boxes, measured once for every mask and filter region. + region_boxes: Option>, + /// How far each symbol's art paints past its box, by name, once measured for a region. + art_reach: HashMap, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, /// Features written approximately (once each). diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index adcd3fd259..129bd83e28 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -2,8 +2,8 @@ use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_vali use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; use super::{ - Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, Writer, - Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, + Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, HashMap, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, + Writer, Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, }; /// A symbol's `` def and the instance transforms that can `` it. @@ -143,9 +143,12 @@ fn pin_to_page(n: &mut Node, m: Affine) { const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; -/// A region covering any artwork (mask and filter extents). +/// The region of a mask or filter over art that has no box to measure: one covering any artwork. const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; +/// How deep [`Writer::paint_reach`] looks into members and symbol art. +const MAX_REACH_DEPTH: u32 = 64; + /// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the /// canvas and PDF take it) rather than from linearised ones. pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; @@ -354,13 +357,18 @@ impl Writer<'_> { return self.node_body(&bare); } let mid = self.fresh_id("mask"); + let mut bare = n.clone(); + bare.extras_mut().mask = None; + // The region covers what the object and the mask art paint: readers draw the mask into an + // image of the region's size. + let region = self.region_attrs(&[&bare, &m.art]); // Mask art is a picture of its own: it takes no part in a knockout group around the object. let knockout = std::mem::take(&mut self.knockout); let art = self.detached(2, |w| w.node(&m.art)); self.knockout = knockout; let inv = m.invert.then(|| { let fid = self.fresh_id("invert"); - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, ""); self.def(1, ""); fid @@ -368,13 +376,13 @@ impl Writer<'_> { // The mask's options, so import restores them (and its art without backdrop and filter). let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; - self.def(1, &format!("")); + self.def(1, &format!("")); if let Some(fid) = &inv { self.def(1, &format!("")); } if !m.clip || m.invert { let c = if m.clip { "black" } else { "white" }; - self.def(1, &format!("")); + self.def(1, &format!("")); } self.defs.push_str(&art); if inv.is_some() { @@ -383,8 +391,6 @@ impl Writer<'_> { self.def(1, ""); self.line(&format!("")); self.depth += 1; - let mut bare = n.clone(); - bare.extras_mut().mask = None; self.node_body(&bare); self.depth -= 1; self.line(""); @@ -399,6 +405,127 @@ impl Writer<'_> { std::mem::replace(&mut self.body, body) } + /// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current + /// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each + /// side for antialiasing. [`BIG`] when none of them has a box, when one holds type (a reader + /// sets live type in its own font, which can be wider, and type on a path is measured by its + /// path), or when one nests deeper than [`MAX_REACH_DEPTH`]. The art of a symbol instance + /// written in place keeps its strokes and effects at their own size while the instance scales + /// its geometry, so the region also covers the geometry's box, placed, grown by the strokes' + /// and effects' reach unscaled. + fn region_attrs(&mut self, nodes: &[&Node]) -> String { + let symbols = self.region_boxes.take().unwrap_or_else(|| self.doc.symbol_boxes()); + let mut art_reach = std::mem::take(&mut self.art_reach); + let region = self.region_of(&symbols, &mut art_reach, nodes); + self.region_boxes = Some(symbols); + self.art_reach = art_reach; + region + } + + /// [`Self::region_attrs`] with the document's symbol boxes and the symbols' art reach so far. + fn region_of(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap, nodes: &[&Node]) -> String { + let mut painted: Option = None; + for n in nodes { + let Some(v) = n.visual_bounds_with(symbols) else { continue }; + let Some(r) = self.paint_reach(symbols, art_reach, n, v, 0) else { + return BIG.to_string(); + }; + let g = n.geometric_bounds_with(symbols).unwrap_or(v); + let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max); + let scaled = self.xf.transform_rect_bbox(v.inflate(r, r)); + let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r); + let both = scaled.union(unscaled); + painted = Some(painted.map_or(both, |p| p.union(both))); + } + let Some(b) = painted.map(|b| b.inflate(2.0, 2.0)).filter(|b| b.is_finite()) else { + return BIG.to_string(); + }; + format!("x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())) + } + + /// How far what `n` paints reaches past its visual box `b`: the reach of its own raster effects + /// and of those of its fills and strokes (measured as [`Self::open_filters`] measures them), + /// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed + /// stroke `w`, as the canvas allows for it), and the most that one of its members or its + /// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each + /// symbol's art reach by name once measured, so art used many times is measured once. A live + /// object is measured as the art it is written as: expanded on its own, as [`Self::node`] + /// expands it, so with the same limits. `None` when `n`, its members or its symbol's art hold + /// type, or nest deeper than [`MAX_REACH_DEPTH`]. + fn paint_reach( + &self, + symbols: &vectorcraft_doc::SymbolBoxes, + art_reach: &mut HashMap, + n: &Node, + b: Rect, + depth: u32, + ) -> Option { + use vectorcraft_effects::RasterFx; + // Type draws past its measured box in a reader's own font. + if matches!(n.kind, NodeKind::Text(_)) { + return None; + } + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 { + vectorcraft_effects::raster_effects(effects) + .iter() + .map(|f| f.outset(b) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }) + .sum() + }; + let items = n.appearance.items.iter().map(|i| reach(i.effects())).fold(0.0, f64::max); + let brush = if vectorcraft_brush::has_brush(n) { + let w = + n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max); + 16.0 * w + 8.0 + } else { + 0.0 + }; + let own = reach(&n.appearance.effects) + items + brush; + if vectorcraft_doc::live::is_live(n) { + if depth >= MAX_REACH_DEPTH { + return None; + } + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + let Some(gb) = g.visual_bounds_with(symbols) else { return Some(own) }; + let past = [b.x0 - gb.x0, b.y0 - gb.y0, gb.x1 - b.x1, gb.y1 - b.y1].into_iter().fold(0.0, f64::max); + return Some(own + past + self.paint_reach(symbols, art_reach, &g, gb, depth + 1)?); + } + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)), + _ => None, + }; + if n.children().is_none_or(|c| c.is_empty()) && symbol.is_none() { + return Some(own); + } + if depth >= MAX_REACH_DEPTH { + return None; + } + let mut inner: f64 = 0.0; + for c in n.children().into_iter().flatten() { + if let Some(cb) = c.visual_bounds_with(symbols) { + inner = inner.max(self.paint_reach(symbols, art_reach, c, cb, depth + 1)?); + } + } + if let Some((s, xf)) = symbol { + let art = match art_reach.get(&s.name) { + Some(r) => *r, + None => { + let r = match s.art.visual_bounds_with(symbols) { + Some(ab) => self.paint_reach(symbols, art_reach, &s.art, ab, depth + 1)?, + None => 0.0, + }; + art_reach.insert(s.name.clone(), r); + r + } + }; + // The art's reach, measured in symbol space, as far as the instance can stretch it. + let [xx, xy, yx, yy, _, _] = xf.as_coeffs(); + inner = inner.max(art * xx.hypot(xy).max(yx.hypot(yy)).max(1.0)); + } + Some(own + inner) + } + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. fn group_props(&self, n: &Node) -> Props { let mut p = css::transparency(n); @@ -450,9 +577,12 @@ impl Writer<'_> { written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c)))); } let after = self.symbol_nodes; + // Each mask covers what the group's elements paint. + let all: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + let region = self.region_attrs(&all); for (c, (before, _)) in rest.iter().zip(&written).rev() { self.symbol_nodes = *before; - let mid = self.knockout_mask(c); + let mid = self.knockout_mask(c, ®ion); self.line(&format!("")); self.depth += 1; } @@ -466,16 +596,18 @@ impl Writer<'_> { } /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask, unless those define its shape). Returns the mask id. - fn knockout_mask(&mut self, c: &Node) -> String { - let filter = match &self.knockout_filter { + /// opacity without its own mask, unless those define its shape). `region` is the attributes of + /// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares. + /// Returns the mask id. + fn knockout_mask(&mut self, c: &Node, region: &str) -> String { + let filter = match self.knockout_filters.get(region) { Some(f) => f.clone(), None => { let f = self.fresh_id("knockout-shape"); - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, ""); self.def(1, ""); - self.knockout_filter = Some(f.clone()); + self.knockout_filters.insert(region.to_string(), f.clone()); f } }; @@ -490,8 +622,8 @@ impl Writer<'_> { let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; let mid = self.fresh_id("knockout"); - self.def(1, &format!("")); - self.def(2, &format!("")); + self.def(1, &format!("")); + self.def(2, &format!("")); self.def(2, &format!("")); self.defs.push_str(&art); self.def(2, ""); diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 65fb26799c..3804d30ed2 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -1,10 +1,11 @@ //! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the //! reference formulas when resvg draws the file, and masks take luminance the canvas's way. +use usvg::roxmltree; use vectorcraft_color::blend::blend_rgb; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, Document, Node, OpacityMask}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, OpacityMask, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; use vectorcraft_svg::{ExportOptions, export}; use vectorcraft_testkit::raster::{Image, render_artboard}; @@ -138,3 +139,126 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() { let svg = export(&d, &ExportOptions::default()); assert!(svg.contains("` and `` element in `svg`. +fn regions(svg: &str) -> Vec<[f64; 4]> { + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); + xml.descendants() + .filter(|n| matches!(n.tag_name().name(), "mask" | "filter")) + .map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN))) + .collect() +} + +/// Mask and filter regions cover the art they apply to and no more: readers draw a mask into an +/// image of its region's size, and one drawn at 200,000 units square runs out of memory. +#[test] +fn mask_and_filter_regions_cover_their_art() { + let knockout = { + let mut d = Document::new(40.0, 20.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [200, 0, 0]); + let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("clip", masked(true, false)), ("no clip, inverted", masked(false, true)), ("knockout", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + // The art is 40 × 20, with 2 to spare on each side. + assert!(x >= -2.5 && y >= -2.5 && w <= 45.0 && h <= 25.0 && w >= 40.0 && h >= 20.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A mask's region reaches as far as the masked art paints: a drop shadow of the object, or of an +/// object in the masked group, shows past the object as on the canvas. +#[test] +fn a_mask_region_keeps_the_shadows_of_the_masked_art() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + for in_group in [false, true] { + let mut d = Document::new(60.0, 60.0); + let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]); + red.appearance.effects.push(shadow.clone()); + let mut masked = if in_group { Node::group(d.alloc_id(), vec![red.into()]) } else { red }; + // An unclipped mask lets the art show outside the mask art: all of the shadow shows. + let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, masked); + let svg = export(&d, &ExportOptions::default()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 60, 60)); + for (x, y) in [(15, 15), (40, 40), (35, 28)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(within(c, w, 3), "in a group {in_group}, at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } + assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40)); + } +} + +/// Type is measured by its layout or its path, and a reader sets live type in its own font, so a +/// mask or knockout over type keeps the region that covers any artwork. +#[test] +fn mask_and_filter_regions_over_type_cover_any_artwork() { + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let text = |d: &mut Document| Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style.clone())))); + let masked = { + let mut d = Document::new(80.0, 40.0); + let mut t = text(&mut d); + let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]); + t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, t); + d + }; + let knockout = { + let mut d = Document::new(80.0, 40.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 80.0, 40.0), [200, 0, 0]); + let mut top = text(&mut d); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("masked type", masked), ("type in a knockout group", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + assert!(x <= -100000.0 && y <= -100000.0 && w >= 200000.0 && h >= 200000.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A live object under a mask is measured as it is written, expanded: the mask's region covers +/// the drop shadows of a blend's steps. +#[test] +fn a_mask_region_covers_a_blend_as_written() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + let mut d = Document::new(100.0, 50.0); + let mut a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), [255, 0, 0]); + let mut b = rect(&mut d, Rect::new(70.0, 10.0, 80.0, 20.0), [0, 0, 255]); + a.appearance.effects.push(shadow.clone()); + b.appearance.effects.push(shadow); + let mut blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![a.into(), b.into()], spec: Default::default() }); + let art = rect(&mut d, Rect::new(0.0, 0.0, 5.0, 5.0), [255, 255, 255]); + blend.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, blend); + let svg = export(&d, &ExportOptions::default()); + // The steps' drop shadows are filters with regions of their own; the mask's covers them all: + // the steps run from 10 to 80 across, and their shadows 15 further. + let xml = roxmltree::Document::parse_with_options(&svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); + let mask = xml.descendants().find(|n| n.has_tag_name("mask")).unwrap(); + let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::().ok()).unwrap_or(f64::NAN)); + assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}"); +} diff --git a/crates/svg/tests/it/mask_options.rs b/crates/svg/tests/it/mask_options.rs index 541a8f702c..16c021391e 100644 --- a/crates/svg/tests/it/mask_options.rs +++ b/crates/svg/tests/it/mask_options.rs @@ -55,6 +55,27 @@ fn an_inverted_unclipped_mask_round_trips() { } } +/// Mask art that reaches past the masked object comes back whole: the mask's region covers the art +/// as well as the object, so import does not clip the art to the region. +#[test] +fn mask_art_larger_than_its_object_round_trips_unclipped() { + let mut d = Document::new(200.0, 200.0); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), red); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 20.0, 180.0, 170.0)), white); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let s = export(&d, &ExportOptions::default()); + let (back, warnings) = import_with_report(&s).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") }; + assert!(m.art.path_data().is_some(), "the art alone, not clipped by the region: {:?}\n{s}", m.art.kind); + let b = m.art.geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 170.0).abs() < 1e-6, "{b:?}"); +} + #[test] fn foreign_masks_keep_their_art() { // Without the attribute a white backdrop is part of the mask art (another app's file). diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index 0fad6ff096..3e58b02286 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -296,6 +296,31 @@ fn masks_in_symbols_move_with_the_art_when_linked() { } } +/// A masked object in a symbol's art, drawn by an instance that scales it down: its stroke keeps +/// its weight, and the mask's region still covers all of it. +#[test] +fn a_mask_in_a_scaled_instance_covers_the_strokes_at_their_weight() { + let mut d = Document::new(120.0, 120.0); + let stroked = Appearance::basic(Paint::solid(Color::rgb8(200, 0, 0)), Paint::solid(Color::BLACK), 20.0); + let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), stroked); + let shape = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + art.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(shape, false))); + d.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(art) }); + instance(&mut d, "Framed", Affine::translate((50.0, 50.0)) * Affine::scale(0.2)); + let svg = export(&d, &ExportOptions::default()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 120, 120)); + // The stroke runs 10 either side of the 20 × 20 square's edge at (50, 50). + for (x, y) in [(43, 60), (77, 60), (60, 43), (60, 77)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(c.iter().all(|v| *v < 60), "the stroke shows on the canvas at {x},{y}: {c:?}"); + assert!(c.iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 8), "at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } +} + #[test] fn symbols_inside_themselves_end() { let mut d = symbol_doc(1.0, &[Affine::translate((50.0, 50.0))]); diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 90c73620a1..4ee448830d 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -547,6 +547,11 @@ impl Tool for SmartPenTool { key == ToolKey::Tab && (self.press.is_some() || self.active(cx).is_some()) } + /// A path being drawn: Escape ends it. + fn ends_on_escape(&self, _cx: &ToolContext) -> bool { + self.path.is_some() + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.stop(); self.marquee = None; diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 70ad9a7e4a..b92f8adfba 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -649,6 +649,10 @@ impl Tool for CurvatureTool { self.id = Some(cx.selection.objects[0]); } } + /// A path being drawn: Escape ends it. + fn ends_on_escape(&self, cx: &ToolContext) -> bool { + self.active(cx).is_some() + } fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { matches!(key, ToolKey::Backspace | ToolKey::Delete) && self.drag.is_none() diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index b4a7d69971..c397231495 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -162,6 +162,12 @@ pub trait Tool: Send { fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool { false } + /// Has the tool started something that Escape ends while no button is pressed (a path being + /// drawn, the Blend tool's first pick)? That Escape ends nothing else, such as isolation mode or + /// a rotated view. + fn ends_on_escape(&self, _cx: &ToolContext) -> bool { + false + } /// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed. fn wants_text(&self) -> bool { false diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 5edc14ad42..d64b4b162b 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -153,6 +153,10 @@ impl Tool for BlendTool { fn busy(&self) -> bool { self.first.is_some() } + /// A first pick, or adding to the blend just made: Escape ends either. + fn ends_on_escape(&self, cx: &ToolContext) -> bool { + self.first(cx).is_some() + } /// Escape drops the object picked first and stops adding to the blend just made, so the next /// click picks a new first object. The document and the selection stay as they are. fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { @@ -525,8 +529,10 @@ mod tests { let c = cx(&d, &s, &p); let mut t = BlendTool { first: None, chained: true, ..Default::default() }; assert!(t.first(&c).is_some()); + assert!(!t.busy() && t.ends_on_escape(&c), "Escape has adding to the blend to end"); assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty()); assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made"); + assert!(!t.ends_on_escape(&c), "and then has nothing to end"); assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop"); } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index e5906db59f..bc564fced3 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -242,6 +242,10 @@ impl Tool for PenTool { fn busy(&self) -> bool { self.drag.is_some() || self.handle.is_some() || self.convert.is_some() } + /// A path being drawn: Escape ends it. + fn ends_on_escape(&self, _cx: &ToolContext) -> bool { + self.path.is_some() + } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let tol = cx.tol(5.0).max(cx.point_tol()); // The path a press began is the selected one by the next event. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 63e311aeae..df87187a65 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -20,7 +20,7 @@ pub(crate) use held_tool::{HeldTool, note_tool_use}; use paste::unclip_chords; pub(crate) use paste::{PasteChord, paste_command}; pub(crate) use presentation::presentation_step; -use presentation::{escape_pair_id, escape_press, escape_resets_rotation, presentation_keys}; +use presentation::{escape_resets_rotation, presentation_keys}; #[cfg(test)] use typing::is_shortcut_chord; pub(crate) use typing::keep_marked_text; @@ -201,8 +201,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::ime::controls_without_editor(app, ctx); return; } - // Taken before anything returns, so a press in a field or a dialog ends a pair of Escapes. - let mut escape_pair = escape_press(ctx); // Followed before anything returns, so a key typed in a field isn't taken for a paste. let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events)); // A numeric field being scrubbed has the keyboard: Escape cancels the drag. @@ -238,7 +236,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } if app.ui.screen_mode == 3 { crate::ime::controls_without_editor(app, ctx); - presentation_keys(app, ctx, escape_pair); + presentation_keys(app, ctx); return; } // Escape that cancels a ruler-origin drag or floating Tools move skips the tool and later Escape actions, @@ -268,7 +266,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { continue; } if k == Key::Escape && escape_ended_gesture { - remember_escape_pair(ctx, k, escape_pair); continue; } // A key the tool claims (Esc with a loaded place cursor) is only the tool's. @@ -285,7 +282,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { .unwrap_or(i.modifiers); crate::canvas::mods(m, false) }); + let ends = k == Key::Escape && app.session.tool_ends_on_escape(view); + let before = (k == Key::Escape && !ends).then(|| document_revision(app)); let r = app.session.tool_key(tk, mods, view); + // An Escape that ended something in the tool (a path being drawn, a selected point) ends + // nothing else. A hover highlight it clears does not count. + let tool_took = ends || before.is_some_and(|b| b != document_revision(app)); // Enter also takes what the tool asks of the UI: Rotate, Scale, Reflect and Shear open // their dialog with it. if claimed || (k == Key::Enter && !busy) { @@ -295,33 +297,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); } - if k == Key::Escape && !busy && !claimed && !escape_held_by_drag { - if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { - let _ = app.run("object.pattern.done", json!({})); - } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { - let _ = app.run("object.exitIsolation", json!({})); - } else if app.session.active().is_some_and(|d| d.global_edit.is_some()) { - let _ = app.run("select.globalEdit", json!({})); - } else if app.ui.flyout.is_some() { - app.ui.flyout = None; - } else if escape_pair.is_some_and(|second| escape_resets_rotation(app, ctx, second)) { - // Escape pressed twice resets a rotated view (with the Rotate View tool, once). - let _ = app.run("view.rotateReset", json!({})); - escape_pair = None; - } else if matches!(app.ui.screen_mode, 1 | 2) - && !egui::Popup::is_any_open(ctx) - && !text_focused_in_previous_frame(ctx) - && ctx.input(|i| { - i.events.iter().any(|e| { - matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) - }) - }) - { - let _ = app.run("view.screenMode", json!({ "mode": 0 })); - } + if k == Key::Escape && !busy && !claimed && !escape_held_by_drag && !tool_took { + escape_ends_one_thing(app, ctx); } - // An Escape that ended something can start a pair too. One that closed a popup cannot. - remember_escape_pair(ctx, k, escape_pair); if k == Key::Enter && !busy && !claimed { // Enter with a selection tool opens the Move dialog, as a double-click on its button // does. @@ -488,13 +466,45 @@ fn escape_ended_gesture(ctx: &egui::Context) -> bool { held } -fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option) { - if key == Key::Escape && escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); +/// An Escape that no tool, field, popup or drag took ends the first of these that is on: pattern +/// editing, isolation mode, Global Edit, a tool flyout, a full screen mode and a rotated view. +fn escape_ends_one_thing(app: &mut VectorcraftApp, ctx: &egui::Context) { + if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { + let _ = app.run("object.pattern.done", json!({})); + } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { + let _ = app.run("object.exitIsolation", json!({})); + } else if app.session.active().is_some_and(|d| d.global_edit.is_some()) { + let _ = app.run("select.globalEdit", json!({})); + } else if app.ui.flyout.is_some() { + app.ui.flyout = None; + } else if app.session.tool_id() == "rotateView" && escape_resets_rotation(app, ctx) { + // With the Rotate View tool, the view resets before a full screen mode ends. + let _ = app.run("view.rotateReset", json!({})); + } else if matches!(app.ui.screen_mode, 1 | 2) + && !egui::Popup::is_any_open(ctx) + && !text_focused_in_previous_frame(ctx) + && ctx.input(|i| { + i.events.iter().any( + |e| matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE), + ) + }) + { + let _ = app.run("view.screenMode", json!({ "mode": 0 })); + } else if escape_resets_rotation(app, ctx) { + // An Escape that has nothing else to end resets a rotated view. After one that ended + // something, the next one does, however long after it comes. A modified Escape does not + // leave a full screen mode, so it resets the view there. + let _ = app.run("view.rotateReset", json!({})); } } +/// What a tool's Escape can change besides what [`vectorcraft_engine::Session::tool_ends_on_escape`] +/// reports: the document's revision (edits and selection changes, such as the Curvature tool +/// letting go of a selected path's current point). +fn document_revision(app: &VectorcraftApp) -> Option { + app.session.active().map(|d| d.revision) +} + /// Dispatch unmodified text keys to command bindings or tools. fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs index da746cbf5f..1c447d09e6 100644 --- a/crates/ui-egui/src/shortcuts/presentation.rs +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -1,45 +1,18 @@ -use super::{Key, VectorcraftApp, is_modifier, json}; +use super::{Key, VectorcraftApp, json}; -/// The second press of Escape pressed twice comes less than this many seconds after the first. -const ESCAPE_PAIR_SECONDS: f64 = 1.0; - -/// The key of the last counted Escape's time in egui's temporary data. -pub(super) fn escape_pair_id() -> egui::Id { - egui::Id::unique("escape-pair") -} - -/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or -/// has another key or pointer button press as well (a key repeat counts as another press, and the -/// modifier keys do not count). Otherwise, whether the last counted Escape came less than -/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the -/// last counted Escape's time. -pub(super) fn escape_press(ctx: &egui::Context) -> Option { - let (escape, other, now) = ctx.input(|i| { - let (mut escape, mut other) = (false, false); - for e in &i.events { - match e { - egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true, - egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true, - egui::Event::PointerButton { pressed: true, .. } => other = true, - _ => {} - } - } - (escape, other, i.time) - }); - if !escape && !other { - return None; - } - let counted = ctx.data_mut(|d| d.remove_temp::(escape_pair_id())); - (escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS)) -} - -/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, -/// neither a ruler-origin drag nor a floating Tools move is ending, and the press is the second of -/// a pair (`second`) or the Rotate View tool is chosen. -pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { +/// Does this Escape reset the view's rotation? The caller has checked that nothing earlier took +/// the press. It does when the view is rotated; no popup, modal window (a progress window, About) +/// or Appearance row popup is open; no text field had the keyboard in the last frame (egui takes +/// the focus away on Escape before this runs); no pointer button is held or let go in this frame +/// (a tab or panel drag takes the Escape, in its release frame too); and neither a ruler-origin +/// drag nor a floating Tools move is ending. +pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context) -> bool { app.view().is_some_and(|v| v.rotation != 0.0) - && (second || app.session.tool_id() == "rotateView") && !egui::Popup::is_any_open(ctx) + && !crate::panel_docking::modal_open(app, ctx) + && app.ui.transient_panel.is_none() + && !super::text_focused_in_previous_frame(ctx) + && !ctx.input(|i| i.pointer.any_down() || i.pointer.any_released()) && !crate::canvas::ruler_origin_dragging(ctx) && !crate::tools_float::escape_cancelled_move(ctx) } @@ -48,7 +21,7 @@ pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, /// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it /// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on /// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). -pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option) { +pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context) { let (mut leave, mut steps) = (false, vec![]); ctx.input_mut(|i| { i.events.retain(|e| match e { @@ -80,11 +53,6 @@ pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, e } if leave { let _ = app.run("view.presentation", json!({})); - // The Escape that left the mode can start a pair, as in `handle`. - if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); - } } } diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index 8f477894a0..017d0175b8 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -262,80 +262,259 @@ fn key(key: egui::Key, repeat: bool) -> Vec { [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat, modifiers: Default::default() }).to_vec() } -/// The second of two Escape presses less than a second apart resets a rotated view. A press of -/// another key or a pointer button between them, or a longer wait, ends the pair. Shift does not. +fn selected(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().selection.objects.len() +} + +/// One Escape resets a rotated view when it has nothing else to end, with or without a selected +/// object, which stays selected. Shift held down with it (as after a Space+Shift drag) does not +/// change that, and the repeats of a held Escape do nothing more. #[test] -fn escape_pressed_twice_resets_a_rotated_view() { +fn one_escape_resets_a_rotated_view() { let mut w = Window::new(); w.escape(0.0); - assert_eq!(w.rotation(), 30.0, "one Escape"); + assert_eq!(w.rotation(), 0.0, "one Escape with nothing selected"); w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "a second Escape 0.5 s later"); - w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "a third Escape leaves the view at 0°"); + assert_eq!(w.rotation(), 0.0, "a second Escape leaves the view at 0°"); let mut w = Window::new(); - let empty = w.app.canvas_rect.unwrap().left_top() + egui::vec2(20.0, 20.0); + w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + w.after(0.0, vec![]); + assert_eq!(selected(&w.app), 1); w.escape(0.0); - w.click(0.2, egui::PointerButton::Primary, empty); - w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "a click between the two"); - let mut w = Window::new(); - w.escape(0.0); - w.after(0.2, key(egui::Key::ArrowRight, false)); - w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "an arrow key between the two"); - let mut w = Window::new(); - w.escape(0.0); - w.after(0.2, key(egui::Key::ShiftLeft, false)); - w.escape(0.2); - assert_eq!(w.rotation(), 0.0, "a Shift press between the two"); + assert_eq!((w.rotation(), selected(&w.app)), (0.0, 1), "one Escape with a selected object, which stays selected"); let mut w = Window::new(); - w.escape(0.0); - w.escape(2.0); - assert_eq!(w.rotation(), 30.0, "two Escapes 2 s apart"); - w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "the later of them starts a pair"); + let shifted = [true, false].map(|pressed| egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::SHIFT, + }); + w.after(0.0, shifted.to_vec()); + assert_eq!(w.rotation(), 0.0, "Shift+Escape"); - // A held Escape repeats: the repeats are not a second press. let mut w = Window::new(); w.after(0.0, vec![key(egui::Key::Escape, false)[0].clone()]); + assert_eq!(w.rotation(), 0.0, "the press of a held Escape"); + w.app.run("view.rotate", json!({"angle": 30})).unwrap(); for _ in 0..5 { w.after(0.1, vec![key(egui::Key::Escape, true)[0].clone()]); } w.after(0.1, vec![key(egui::Key::Escape, false)[1].clone()]); - assert_eq!(w.rotation(), 30.0, "Escape held down"); + assert_eq!(w.rotation(), 30.0, "its repeats"); + + let mut w = Window::new(); + w.app.select_tool("rotateView"); + w.escape(0.0); + assert_eq!(w.rotation(), 0.0, "one Escape with the Rotate View tool"); + + // A hover highlight that the Escape clears does not count as something it ends. + let mut w = Window::new(); + w.app.run("shape.rectangle", json!({"x": 150, "y": 100, "width": 120, "height": 80})).unwrap(); + // The new object is the selection, to be put on the path under the pointer. + w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + w.app.select_tool("objectsOnPath"); + let view = w.app.view_info(); + let over = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, 150.0, 140.0); + w.app.session.pointer(&over, view).unwrap(); + assert!(!w.app.session.overlays(view).is_empty(), "Objects on Path highlights the path under the pointer"); + w.escape(0.0); + assert_eq!(w.rotation(), 0.0, "one Escape with Objects on Path hovering"); } -/// The first Escape of a pair may end something, such as isolation mode. With the Rotate View -/// tool, one Escape resets the view. +/// An Escape that ends something, such as isolation mode, leaves the view as it is. The next +/// Escape resets it, however long after it comes and whatever is clicked or pressed in between. #[test] -fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view() { +fn escape_after_ending_isolation_resets_the_view() { + let isolated_window = || { + let mut w = Window::new(); + w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + w.app.run("shape.rectangle", json!({"x": 80, "y": 20, "width": 40, "height": 40})).unwrap(); + w.app.run("select.all", json!({})).unwrap(); + w.app.run("object.group", json!({})).unwrap(); + menus::invoke(&mut w.app, "object.isolate", json!({})); + w.after(0.0, vec![]); + w + }; + let isolated = |w: &Window| w.app.session.active().unwrap().isolation.is_some(); + let mut w = isolated_window(); + assert!(isolated(&w)); + w.escape(0.0); + assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the first Escape leaves isolation mode"); + w.escape(5.0); + assert_eq!(w.rotation(), 0.0, "the second resets the view 5 s later"); + + let mut w = isolated_window(); + let empty = w.app.canvas_rect.unwrap().left_top() + egui::vec2(20.0, 20.0); + w.escape(0.0); + assert_eq!((isolated(&w), w.rotation()), (false, 30.0)); + w.click(2.0, egui::PointerButton::Primary, empty); + w.after(0.5, key(egui::Key::ArrowRight, false)); + w.escape(3.0); + assert_eq!(w.rotation(), 0.0, "after a click and an arrow key between the two"); +} + +/// An Escape that ends the path the Pen, the Smart Pen or the Curvature tool is drawing leaves the +/// view as it is, with the rubber band shown or not; the next Escape resets it. +#[test] +fn escape_that_ends_a_path_being_drawn_keeps_the_rotation() { + for (tool, rubber_band) in [("pen", true), ("pen", false), ("smartPen", true), ("smartPen", false), ("curvature", true)] { + let mut w = Window::new(); + w.app.session.prefs.pen_rubber_band = rubber_band; + w.app.select_tool(tool); + let c = w.app.canvas_rect.unwrap().center(); + w.click(0.0, egui::PointerButton::Primary, c); + w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0)); + w.after(0.1, vec![egui::Event::PointerMoved(c + egui::vec2(60.0, 80.0))]); + assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: a path is being drawn"); + w.escape(0.2); + assert_eq!(w.rotation(), 30.0, "{tool}, rubber band {rubber_band}: the Escape that ends the path"); + assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: the path is ended"); + w.escape(3.0); + assert_eq!(w.rotation(), 0.0, "{tool}, rubber band {rubber_band}: the next Escape"); + } +} + +/// In isolation mode, an Escape that ends the Pen's path ends only the path: the next Escape leaves +/// isolation mode, and the one after it resets the view. +#[test] +fn escape_ends_one_thing_at_a_time() { let mut w = Window::new(); w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); w.app.run("shape.rectangle", json!({"x": 80, "y": 20, "width": 40, "height": 40})).unwrap(); w.app.run("select.all", json!({})).unwrap(); w.app.run("object.group", json!({})).unwrap(); menus::invoke(&mut w.app, "object.isolate", json!({})); + w.after(0.0, vec![]); let isolated = |w: &Window| w.app.session.active().unwrap().isolation.is_some(); assert!(isolated(&w)); - w.escape(0.0); - assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the first Escape leaves isolation mode"); - w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "the second resets the view"); - - let mut w = Window::new(); - w.app.select_tool("rotateView"); - w.escape(0.0); - assert_eq!(w.rotation(), 0.0, "one Escape with the Rotate View tool"); + w.app.select_tool("pen"); + let c = w.app.canvas_rect.unwrap().center(); + w.click(0.0, egui::PointerButton::Primary, c); + w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0)); + assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "a path is being drawn"); + w.escape(0.2); + assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "the first Escape ends the path"); + assert_eq!((isolated(&w), w.rotation()), (true, 30.0), "and only the path"); + w.escape(0.2); + assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the second leaves isolation mode"); + w.escape(0.2); + assert_eq!(w.rotation(), 0.0, "the third resets the view"); } -/// An Escape that closes a popup (the status bar's angle menu, the canvas context menu) does not -/// count, so the Escape after it leaves the view as it is. An Escape while the ruler origin is -/// dragged does not reset the view either, but it counts as the first Escape of a pair. +/// The Escape that leaves the Artboard tool leaves the view as it is; the next resets it. #[test] -fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() { +fn escape_after_leaving_the_artboard_tool_resets_the_view() { + let mut w = Window::new(); + w.app.select_tool("artboard"); + w.escape(0.0); + assert_eq!((w.app.session.tool_id(), w.rotation()), ("selection", 30.0), "the first Escape leaves the Artboard tool"); + w.escape(5.0); + assert_eq!(w.rotation(), 0.0, "the next resets the view"); +} + +/// An Escape that a dialog or a full screen mode takes leaves the view as it is; the next resets it. +#[test] +fn escape_in_a_dialog_or_a_full_screen_mode_keeps_the_rotation() { + let mut w = Window::new(); + menus::invoke(&mut w.app, "view.rotate", json!({})); + w.after(0.0, vec![]); + assert!(w.app.ui.dialog.is_some(), "Rotate View asks for an angle"); + w.escape(0.0); + assert_eq!((w.app.ui.dialog.is_none(), w.rotation()), (true, 30.0), "Escape closes the dialog"); + w.escape(0.5); + assert_eq!(w.rotation(), 0.0, "the next Escape"); + + for mode in [1, 2] { + let mut w = Window::new(); + w.app.ui.screen_mode = mode; + w.after(0.0, vec![]); + w.escape(0.0); + assert_eq!((w.app.ui.screen_mode, w.rotation()), (0, 30.0), "Escape leaves screen mode {mode}"); + w.escape(0.5); + assert_eq!(w.rotation(), 0.0, "the next Escape after screen mode {mode}"); + + // Shift+Escape does not leave a full screen mode, so it resets the view there. + let mut w = Window::new(); + w.app.ui.screen_mode = mode; + w.after(0.0, vec![]); + let shifted = [true, false].map(|pressed| egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::SHIFT, + }); + w.after(0.0, shifted.to_vec()); + assert_eq!((w.app.ui.screen_mode, w.rotation()), (mode, 0.0), "Shift+Escape in screen mode {mode}"); + } + + let mut w = Window::new(); + w.app.run("view.presentation", json!({})).unwrap(); + w.after(0.0, vec![]); + assert_eq!(w.app.ui.screen_mode, 3, "Presentation Mode"); + w.escape(0.0); + assert_eq!((w.app.ui.screen_mode != 3, w.rotation()), (true, 30.0), "Escape leaves Presentation Mode"); + w.escape(0.5); + assert_eq!(w.rotation(), 0.0, "the next Escape after Presentation Mode"); +} + +/// An Escape while About or an Appearance row's popup is open, or while a pointer button is held +/// outside the canvas (a scroll bar, a tab or a panel being dragged) or let go in the same frame, +/// leaves the view as it is. +#[test] +fn escape_with_a_window_open_or_a_button_held_keeps_the_rotation() { + let mut w = Window::new(); + w.app.ui.about = true; + w.after(0.0, vec![]); + w.escape(0.0); + assert_eq!(w.rotation(), 30.0, "an Escape with About open"); + + // An Appearance row's popup (here, the Stroke panel's) closes on Escape as the frame is drawn. + let mut w = Window::new(); + w.app.ui.transient_panel = Some("stroke".into()); + w.escape(0.0); + assert_eq!(w.rotation(), 30.0, "an Escape with an Appearance row's popup open"); + + let mut w = Window::new(); + let bar = w.app.canvas_rect.unwrap().right_center() + egui::vec2(4.0, 0.0); + let button = |pressed| egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + w.after(0.0, vec![egui::Event::PointerMoved(bar), button(true)]); + w.escape(0.1); + assert_eq!(w.rotation(), 30.0, "an Escape with the button held"); + let mut release = vec![button(false)]; + release.extend(key(egui::Key::Escape, false)); + w.after(0.1, release); + assert_eq!(w.rotation(), 30.0, "an Escape in the frame the button is let go"); + w.escape(0.1); + assert_eq!(w.rotation(), 0.0, "an Escape after the release"); +} + +/// An Escape in a text field ends the editing and leaves the view as it is. +#[test] +fn escape_in_a_text_field_keeps_the_rotation() { + let mut w = Window::new(); + w.ctx.enable_accesskit(); + let out = w.after(0.0, vec![]); + let tree = out.platform_output.accesskit_update.expect("the window's accessibility tree"); + let field = tree.nodes.iter().find(|(_, node)| node.role() == egui::accesskit::Role::TextInput).map(|(id, _)| *id).expect("a text field"); + let field = w + .ctx + .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter()).find(|r| r.id.accesskit_id() == field).copied()) + .expect("the text field's widget"); + w.ctx.memory_mut(|m| m.request_focus(field.id)); + w.after(0.0, vec![]); + assert!(w.ctx.egui_wants_keyboard_input(), "the field has the keyboard"); + w.escape(0.0); + assert_eq!(w.rotation(), 30.0, "the Escape in the field"); +} + +/// An Escape that closes a popup (the status bar's angle menu, the canvas context menu) or ends a +/// drag of the ruler origin leaves the view as it is; the next Escape resets it. +#[test] +fn escape_that_closes_a_popup_or_ends_a_ruler_origin_drag_keeps_the_rotation() { let mut w = Window::new(); // Find the actual combo by its accessible name, which is present in both build profiles; // `Id`'s debug source (including "rot-combo") only exists with debug assertions enabled. @@ -354,8 +533,9 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation assert!(craft_ui::ComboBox::is_open(&w.ctx, combo.id), "the angle menu is open"); w.escape(0.2); assert!(!egui::Popup::is_any_open(&w.ctx), "Escape closes it"); + assert_eq!(w.rotation(), 30.0, "the Escape that closes the angle menu"); w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "an Escape after the angle menu closed"); + assert_eq!(w.rotation(), 0.0, "the next Escape"); let mut w = Window::new(); w.click(0.0, egui::PointerButton::Secondary, w.app.canvas_rect.unwrap().center()); @@ -363,8 +543,9 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation assert!(egui::Popup::is_any_open(&w.ctx), "a right click opens the context menu"); w.escape(0.2); assert!(!egui::Popup::is_any_open(&w.ctx), "Escape closes it"); + assert_eq!(w.rotation(), 30.0, "the Escape that closes the context menu"); w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "an Escape after the context menu closed"); + assert_eq!(w.rotation(), 0.0, "the next Escape"); // With the Rotate View tool, the Escape that closes the menu leaves the view as it is too. let mut w = Window::new(); w.app.select_tool("rotateView"); @@ -391,5 +572,5 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation assert_eq!(w.rotation(), 30.0, "two Escapes while the ruler origin is dragged"); w.after(0.1, vec![button(to, false)]); w.escape(0.2); - assert_eq!(w.rotation(), 0.0, "an Escape after the drag ends pairs with the last one"); + assert_eq!(w.rotation(), 0.0, "an Escape after the drag ends"); } diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 56bcca519a..3154ccb6d9 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -677,7 +677,7 @@ fn a_tool_flyout_takes_escape_before_full_screen() { } #[test] -fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() { +fn escape_exits_full_screen_then_resets_the_rotation_with_selection_tool() { for mode in [1, 2] { let ctx = egui::Context::default(); let mut app = in_screen_mode(&ctx, mode); @@ -686,7 +686,7 @@ fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() { assert_eq!(app.ui.screen_mode, 0, "first Escape in mode {mode}"); assert_eq!(app.view().unwrap().rotation, 30.0); frame(&mut app, &ctx, escape()); - assert_eq!(app.view().unwrap().rotation, 0.0, "second Escape after mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 0.0, "the next Escape after mode {mode}"); } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 10dded6237..b1c305db1d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -286,7 +286,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 85dea9665f..c0258b4b59 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1035,7 +1035,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle). View › Rotate View to Selection turns the view to the angle of the selected objects' bounding box, so the box is square to the window (`view.rotateToSelection`). When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then. While the desktop app's render worker renders the frame for a new view, the parts of the canvas the last frame does not cover after a pan, a zoom-out or a turn show the art from a frame that a second worker renders at a quarter of the resolution, covering twice the canvas's width and height. That frame draws images only from pixels already decoded (an image not yet decoded shows as a light gray box), and it renders large raster effects at the lower resolution a drag uses. Until it is ready, those parts show the pasteboard and the artboards. They used to show only the pasteboard and the artboards until the frame for the new view was ready. - With the view rotated, a second Escape less than a second after the first resets the rotation, as Reset Rotate View does. The first Escape may end something else, such as isolation mode or a path being drawn. The second resets the view only when it has nothing else to end, and an Escape that closes a menu does not count. After a click, or a press of another key that is not a modifier (Shift, Control, Alt/Option or Command), the next Escape counts as the first. With the Rotate View tool chosen, one Escape resets the view. + With the view rotated, an Escape that has nothing else to end resets the rotation, as Reset Rotate View does, with or without a selection, which stays selected. An Escape that ends something else ends only that, and the next Escape resets the view: to reset the view while another tool or feature is in use, Escape is pressed twice ([Rotate canvas view](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/rotate-canvas-view.html)). Under VectorCraft's own rule, the things an Escape ends first are isolation mode, pattern editing, Global Edit, a tool flyout, a full screen mode, Presentation Mode, a path being drawn with the Pen, Smart Pen or Curvature tool, the Blend tool's first pick, the Artboard tool, a tool's drag, a menu, a dialog, a text field and a drag of the ruler origin, and the second press may come any time later, after clicks or other keys. With the Rotate View tool, the view resets before a full screen mode ends; with another tool, Escape leaves a full screen mode first, and Shift+Escape, which does not leave it, resets the view. An Escape does not reset the view while About, a progress window or an Appearance row's popup is open, while a pointer button is held, or in the frame a button is let go. It used to reset the view only as the second of two Escape presses less than a second apart. A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default). On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`). A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`).